From 1e20538d7f3f23539508ac75a1835f634f508fc1 Mon Sep 17 00:00:00 2001 From: sehyuk080101 Date: Thu, 26 Feb 2026 21:44:50 +0900 Subject: [PATCH 01/22] feat: add service --- clients/client-git/build.gradle.kts | 7 + .../kotlin/kr/proxia/client/git/GitConfig.kt | 17 +++ .../kr/proxia/client/git/GitProperties.kt | 9 ++ .../client/git/github/GitHubAppJwtProvider.kt | 40 ++++++ .../git/github/GitHubConnectionClient.kt | 24 ++++ .../client/git/github/GitHubProperties.kt | 6 + .../git/github/GitHubRepositoriesResponse.kt | 9 ++ .../client/git/github/GitHubRepository.kt | 15 ++ .../git/github/GitHubRepositoryClient.kt | 31 ++++ .../git/github/InstallationTokenResponse.kt | 15 ++ .../client/oauth/github/GitHubOAuthClient.kt | 5 +- .../client/oauth/google/GoogleOAuthClient.kt | 5 +- core/core-api/build.gradle.kts | 1 + .../controller/v1/GitConnectionController.kt | 64 +++++++++ .../controller/v1/GitRepositoryController.kt | 35 +++++ .../api/controller/v1/ServiceController.kt | 98 +++++++++++++ .../v1/request/CreateAppServiceRequest.kt | 40 ++++++ .../request/CreateDatabaseServiceRequest.kt | 25 ++++ .../v1/request/CreateGitConnectionRequest.kt | 16 +++ .../v1/response/GitConnectionResponse.kt | 10 ++ .../v1/response/GitRepositoryResponse.kt | 5 + .../v1/response/ServiceDetailResponse.kt | 7 + .../controller/v1/response/ServiceResponse.kt | 11 ++ .../v1/response/ServiceTypeResponse.kt | 29 ++++ .../kr/proxia/core/domain/CreateAppService.kt | 17 +++ .../core/domain/CreateDatabaseService.kt | 10 ++ .../proxia/core/domain/CreateGitConnection.kt | 9 ++ .../domain/DatabaseCredentialGenerator.kt | 13 ++ .../core/domain/DatabaseEngineConfig.kt | 54 +++++++ .../core/domain/GitConnectionService.kt | 81 +++++++++++ .../kr/proxia/core/domain/GitRepository.kt | 5 + .../core/domain/GitRepositoryService.kt | 36 +++++ .../kr/proxia/core/domain/ServiceService.kt | 134 ++++++++++++++++++ .../kr/proxia/core/support/error/ErrorType.kt | 9 ++ settings.gradle.kts | 1 + .../storage/db/core/entity/GitConnection.kt | 1 + .../repository/GitConnectionRepository.kt | 4 + .../db/core/repository/ServiceRepository.kt | 10 +- .../db/core/repository/WorkspaceRepository.kt | 15 ++ 39 files changed, 918 insertions(+), 5 deletions(-) create mode 100644 clients/client-git/build.gradle.kts create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/GitProperties.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubProperties.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/InstallationTokenResponse.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateDatabaseServiceRequest.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitConnectionResponse.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitRepositoryResponse.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateAppService.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateDatabaseService.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateGitConnection.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseEngineConfig.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepository.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt diff --git a/clients/client-git/build.gradle.kts b/clients/client-git/build.gradle.kts new file mode 100644 index 0000000..c4538ce --- /dev/null +++ b/clients/client-git/build.gradle.kts @@ -0,0 +1,7 @@ +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + + api("io.jsonwebtoken:jjwt-api:${property("jjwtVersion")}") + runtimeOnly("io.jsonwebtoken:jjwt-impl:${property("jjwtVersion")}") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:${property("jjwtVersion")}") +} diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt new file mode 100644 index 0000000..768d94b --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt @@ -0,0 +1,17 @@ +package kr.proxia.client.git + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient + +@Configuration +@EnableConfigurationProperties(GitProperties::class) +class GitConfig { + @Bean + fun githubRestClient(): RestClient = + RestClient + .builder() + .baseUrl("https://github.com") + .build() +} diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitProperties.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitProperties.kt new file mode 100644 index 0000000..583ccb3 --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitProperties.kt @@ -0,0 +1,9 @@ +package kr.proxia.client.git + +import kr.proxia.client.git.github.GitHubProperties +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "git") +data class GitProperties( + val github: GitHubProperties, +) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt new file mode 100644 index 0000000..afc37c6 --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt @@ -0,0 +1,40 @@ +package kr.proxia.client.git.github + +import io.jsonwebtoken.Jwts +import org.springframework.stereotype.Component +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import java.util.Date + +@Component +class GitHubAppJwtProvider { + fun createAsyncJwt(appId: String, privateKeyPem: String): String { + val now = System.currentTimeMillis() + val issuedAt = Date(now - 60_000) + val expiration = Date(now + 600_000) + + return Jwts.builder() + .issuer(appId) + .issuedAt(issuedAt) + .expiration(expiration) + .signWith(decodePrivateKey(privateKeyPem)) + .compact() + } + + private fun decodePrivateKey(pem: String): PrivateKey { + val cleanPem = pem + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + val decoded = Base64.getDecoder().decode(cleanPem) + val spec = PKCS8EncodedKeySpec(decoded) + val kf = KeyFactory.getInstance("RSA") + + return kf.generatePrivate(spec) + } +} diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt new file mode 100644 index 0000000..8a6af71 --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt @@ -0,0 +1,24 @@ +package kr.proxia.client.git.github + +import kr.proxia.client.git.GitProperties +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +@Component +class GitHubConnectionClient( + private val githubRestClient: RestClient, + private val jwtProvider: GitHubAppJwtProvider, + private val properties: GitProperties, +) { + fun getInstallationToken(installationId: String): InstallationTokenResponse { + val jwt = jwtProvider.createAsyncJwt(properties.github.appId, properties.github.privateKey) + + return githubRestClient.post() + .uri("https://api.github.com/app/installations/$installationId/access_tokens") + .header("Authorization", "Bearer $jwt") + .header("Accept", "application/vnd.github+json") + .retrieve() + .body()!! + } +} diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubProperties.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubProperties.kt new file mode 100644 index 0000000..6b2a9dc --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubProperties.kt @@ -0,0 +1,6 @@ +package kr.proxia.client.git.github + +data class GitHubProperties( + val appId: String, + val privateKey: String, +) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt new file mode 100644 index 0000000..7d20cb3 --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt @@ -0,0 +1,9 @@ +package kr.proxia.client.git.github + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GitHubRepositoriesResponse( + @JsonProperty("total_count") + val totalCount: Int, + val repositories: List +) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt new file mode 100644 index 0000000..dbb76a7 --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt @@ -0,0 +1,15 @@ +package kr.proxia.client.git.github + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GitHubRepository( + val id: Long, + val name: String, + @JsonProperty("full_name") + val fullName: String, + @JsonProperty("default_branch") + val defaultBranch: String, + val private: Boolean, + @JsonProperty("html_url") + val htmlUrl: String +) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt new file mode 100644 index 0000000..37c869b --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt @@ -0,0 +1,31 @@ +package kr.proxia.client.git.github + +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +@Component +class GitHubRepositoryClient( + private val githubRestClient: RestClient, +) { + fun getGithubRepositories(accessToken: String): List = + githubRestClient.get() + .uri("https://api.github.com/installation/repositories") + .header("Authorization", "Bearer $accessToken") + .header("Accept", "application/vnd.github+json") + .retrieve() + .body()!!.repositories + + fun verifyRepositoryAccess(token: String, repoFullName: String): Boolean = + try { + githubRestClient.get() + .uri("https://api.github.com/repos/$repoFullName") + .header("Authorization", "Bearer $token") + .header("Accept", "application/vnd.github+json") + .retrieve() + .toBodilessEntity() + .statusCode.is2xxSuccessful + } catch (_: Exception) { + false + } +} diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/InstallationTokenResponse.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/InstallationTokenResponse.kt new file mode 100644 index 0000000..d0ce24d --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/InstallationTokenResponse.kt @@ -0,0 +1,15 @@ +package kr.proxia.client.git.github + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.LocalDateTime + +data class InstallationTokenResponse( + val token: String, + @JsonProperty("expires_at") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC") + val expiresAt: LocalDateTime, + val permissions: Map? = null, + @JsonProperty("repository_selection") + val repositorySelection: String? = null, +) diff --git a/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/github/GitHubOAuthClient.kt b/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/github/GitHubOAuthClient.kt index 675a11b..7abdcff 100644 --- a/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/github/GitHubOAuthClient.kt +++ b/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/github/GitHubOAuthClient.kt @@ -6,6 +6,7 @@ import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.web.client.RestClient +import org.springframework.web.client.body @Component class GitHubOAuthClient( @@ -29,7 +30,7 @@ class GitHubOAuthClient( .accept(MediaType.APPLICATION_JSON) .body(params) .retrieve() - .body(GitHubTokenResponse::class.java)!! + .body()!! } fun getUserInfo(accessToken: String): GitHubUserInfo = @@ -38,7 +39,7 @@ class GitHubOAuthClient( .uri("/user") .header("Authorization", "Bearer $accessToken") .retrieve() - .body(GitHubUserInfo::class.java)!! + .body()!! fun getUserEmails(accessToken: String): List = githubApiRestClient diff --git a/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/google/GoogleOAuthClient.kt b/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/google/GoogleOAuthClient.kt index 9888120..51bfdfd 100644 --- a/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/google/GoogleOAuthClient.kt +++ b/clients/client-oauth/src/main/kotlin/kr/proxia/client/oauth/google/GoogleOAuthClient.kt @@ -5,6 +5,7 @@ import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.web.client.RestClient +import org.springframework.web.client.body @Component class GoogleOAuthClient( @@ -28,7 +29,7 @@ class GoogleOAuthClient( .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(params) .retrieve() - .body(GoogleTokenResponse::class.java)!! + .body()!! } fun getUserInfo(accessToken: String): GoogleUserInfo = @@ -37,5 +38,5 @@ class GoogleOAuthClient( .uri("/oauth2/v2/userinfo") .header("Authorization", "Bearer $accessToken") .retrieve() - .body(GoogleUserInfo::class.java)!! + .body()!! } diff --git a/core/core-api/build.gradle.kts b/core/core-api/build.gradle.kts index 56e8531..6d46240 100644 --- a/core/core-api/build.gradle.kts +++ b/core/core-api/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(project(":storage:db-core")) implementation(project(":clients:client-oauth")) implementation(project(":clients:client-aws")) + implementation(project(":clients:client-git")) implementation(project(":support:logging")) implementation(project(":support:monitoring")) implementation(project(":support:security")) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt new file mode 100644 index 0000000..1b63c71 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt @@ -0,0 +1,64 @@ +package kr.proxia.core.api.controller.v1 + +import kr.proxia.core.api.controller.v1.request.CreateGitConnectionRequest +import kr.proxia.core.api.controller.v1.response.GitConnectionResponse +import kr.proxia.core.domain.GitConnectionService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/workspaces/{workspaceId}/git/connections") +class GitConnectionController( + private val gitConnectionService: GitConnectionService, +) { + @GetMapping + fun getConnections( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + ): List { + val connections = gitConnectionService.getConnections( + userId = userId, + workspaceId = workspaceId, + ) + + return connections.map { + GitConnectionResponse( + id = it.id, + name = it.name, + provider = it.provider, + ) + } + } + + @PostMapping + fun createConnection( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @RequestBody request: CreateGitConnectionRequest, + ) { + gitConnectionService.createConnection( + userId = userId, + workspaceId = workspaceId, + createGitConnection = request.toDomain(), + ) + } + + @PostMapping("/{connectionId}") + fun deleteConnection( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @PathVariable connectionId: UUID, + ) { + gitConnectionService.deleteConnection( + userId = userId, + workspaceId = workspaceId, + connectionId = connectionId, + ) + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt new file mode 100644 index 0000000..8b188fd --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt @@ -0,0 +1,35 @@ +package kr.proxia.core.api.controller.v1 + +import kr.proxia.core.api.controller.v1.response.GitRepositoryResponse +import kr.proxia.core.domain.GitRepositoryService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/workspaces/{workspaceId}/git/connections/{connectionId}/repositories") +class GitRepositoryController( + private val gitRepositoryService: GitRepositoryService, +) { + @GetMapping + fun getRepositories( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @PathVariable connectionId: UUID, + ): List { + val repositories = gitRepositoryService.getRepositories( + userId = userId, + workspaceId = workspaceId, + connectionId = connectionId, + ) + + return repositories.map { + GitRepositoryResponse( + name = it.name, + ) + } + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt new file mode 100644 index 0000000..0beecd1 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt @@ -0,0 +1,98 @@ +package kr.proxia.core.api.controller.v1 + +import kr.proxia.core.api.controller.v1.request.CreateAppServiceRequest +import kr.proxia.core.api.controller.v1.request.CreateDatabaseServiceRequest +import kr.proxia.core.api.controller.v1.response.ServiceDetailResponse +import kr.proxia.core.api.controller.v1.response.ServiceResponse +import kr.proxia.core.api.controller.v1.response.ServiceTypeResponse +import kr.proxia.core.domain.ServiceService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/workspaces/{workspaceId}/projects/{projectId}/services") +class ServiceController( + private val serviceService: ServiceService, +) { + @GetMapping + fun getServices( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @PathVariable projectId: UUID, + ): List { + val services = serviceService.getServices(userId, workspaceId, projectId) + + return services.map { + ServiceResponse( + id = it.id, + name = it.name, + x = it.x, + y = it.y, + status = it.status.name, + ) + } + } + + @GetMapping("/{serviceId}") + fun getService( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @PathVariable projectId: UUID, + @PathVariable serviceId: UUID, + ): ServiceDetailResponse { + TODO() + } + + @PostMapping("/app") + fun createAppService( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @PathVariable projectId: UUID, + @RequestBody request: CreateAppServiceRequest, + ): ServiceResponse { + val service = serviceService.createAppService(userId, workspaceId, projectId, request.toDomain()) + + return ServiceResponse( + id = service.id, + name = service.name, + x = service.x, + y = service.y, + status = service.status.name, + ) + } + + @PostMapping("/database") + fun createDatabaseService( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @PathVariable projectId: UUID, + @RequestBody request: CreateDatabaseServiceRequest, + ): ServiceResponse { + val service = serviceService.createDatabaseService(userId, workspaceId, projectId, request.toDomain()) + + return ServiceResponse( + id = service.id, + name = service.name, + x = service.x, + y = service.y, + status = service.status.name, + ) + } + + @DeleteMapping("/{serviceId}") + fun deleteService( + @AuthenticationPrincipal userId: UUID, + @PathVariable workspaceId: UUID, + @PathVariable projectId: UUID, + @PathVariable serviceId: UUID, + ) { + serviceService.deleteService(userId, projectId, serviceId) + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt new file mode 100644 index 0000000..b6cc12c --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt @@ -0,0 +1,40 @@ +package kr.proxia.core.api.controller.v1.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import kr.proxia.core.domain.CreateAppService +import kr.proxia.core.enums.Framework +import java.util.UUID + +data class CreateAppServiceRequest( + @field:NotBlank + val name: String, + @field:NotNull + val x: Double, + @field:NotNull + val y: Double, + @field:NotBlank + val repoFullName: String, + val branch: String?, + val port: Int?, + @field:NotNull + val framework: Framework, + val buildCommand: String?, + val startCommand: String?, + @field:NotBlank + val gitConnectionId: UUID, +) { + fun toDomain(): CreateAppService = + CreateAppService( + name = name, + x = x, + y = y, + repoFullName = repoFullName, + branch = branch, + port = port, + framework = framework, + buildCommand = buildCommand, + startCommand = startCommand, + gitConnectionId = gitConnectionId, + ) +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateDatabaseServiceRequest.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateDatabaseServiceRequest.kt new file mode 100644 index 0000000..5ee3c5d --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateDatabaseServiceRequest.kt @@ -0,0 +1,25 @@ +package kr.proxia.core.api.controller.v1.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import kr.proxia.core.domain.CreateDatabaseService +import kr.proxia.core.enums.DatabaseEngine + +data class CreateDatabaseServiceRequest( + @field:NotBlank + val name: String, + @field:NotNull + val x: Double, + @field:NotNull + val y: Double, + @field:NotNull + val engine: DatabaseEngine, +) { + fun toDomain(): CreateDatabaseService = + CreateDatabaseService( + name = name, + x = x, + y = y, + engine = engine, + ) +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt new file mode 100644 index 0000000..6fc32f1 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt @@ -0,0 +1,16 @@ +package kr.proxia.core.api.controller.v1.request + +import kr.proxia.core.domain.CreateGitConnection +import kr.proxia.core.enums.GitProvider + +data class CreateGitConnectionRequest( + val installationId: String, + val name: String, + val provider: GitProvider, +) { + fun toDomain(): CreateGitConnection = CreateGitConnection( + installationId = installationId, + name = name, + provider = provider, + ) +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitConnectionResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitConnectionResponse.kt new file mode 100644 index 0000000..983f6d1 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitConnectionResponse.kt @@ -0,0 +1,10 @@ +package kr.proxia.core.api.controller.v1.response + +import kr.proxia.core.enums.GitProvider +import java.util.UUID + +data class GitConnectionResponse( + val id: UUID, + val name: String, + val provider: GitProvider, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitRepositoryResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitRepositoryResponse.kt new file mode 100644 index 0000000..17fb066 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/GitRepositoryResponse.kt @@ -0,0 +1,5 @@ +package kr.proxia.core.api.controller.v1.response + +data class GitRepositoryResponse( + val name: String, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt new file mode 100644 index 0000000..a390246 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt @@ -0,0 +1,7 @@ +package kr.proxia.core.api.controller.v1.response + +import java.util.UUID + +data class ServiceDetailResponse( + val id: UUID, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt new file mode 100644 index 0000000..cb92030 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt @@ -0,0 +1,11 @@ +package kr.proxia.core.api.controller.v1.response + +import java.util.UUID + +data class ServiceResponse( + val id: UUID, + val name: String, + val x: Double, + val y: Double, + val status: String, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt new file mode 100644 index 0000000..8fb611d --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt @@ -0,0 +1,29 @@ +package kr.proxia.core.api.controller.v1.response + +import kr.proxia.core.support.error.CoreException +import kr.proxia.core.support.error.ErrorType +import kr.proxia.storage.db.core.entity.AppService +import kr.proxia.storage.db.core.entity.DatabaseService +import kr.proxia.storage.db.core.entity.Service + +data class ServiceTypeResponse( + val category: String, + val techStack: String, +) { + companion object { + fun from(service: Service) = + when (service) { + is AppService -> ServiceTypeResponse( + category = "app", + techStack = service.framework.name, + ) + + is DatabaseService -> ServiceTypeResponse( + category = "database", + techStack = service.engine.name, + ) + + else -> throw CoreException(ErrorType.UNSUPPORTED_SERVICE_TYPE) + } + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateAppService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateAppService.kt new file mode 100644 index 0000000..2f4f419 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateAppService.kt @@ -0,0 +1,17 @@ +package kr.proxia.core.domain + +import kr.proxia.core.enums.Framework +import java.util.UUID + +data class CreateAppService( + val name: String, + val x: Double, + val y: Double, + val repoFullName: String, + val branch: String?, + val port: Int?, + val framework: Framework, + val buildCommand: String?, + val startCommand: String?, + val gitConnectionId: UUID, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateDatabaseService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateDatabaseService.kt new file mode 100644 index 0000000..8750f13 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateDatabaseService.kt @@ -0,0 +1,10 @@ +package kr.proxia.core.domain + +import kr.proxia.core.enums.DatabaseEngine + +data class CreateDatabaseService( + val name: String, + val x: Double, + val y: Double, + val engine: DatabaseEngine, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateGitConnection.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateGitConnection.kt new file mode 100644 index 0000000..bf2eab2 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/CreateGitConnection.kt @@ -0,0 +1,9 @@ +package kr.proxia.core.domain + +import kr.proxia.core.enums.GitProvider + +data class CreateGitConnection( + val installationId: String, + val name: String, + val provider: GitProvider, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt new file mode 100644 index 0000000..ea5b119 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt @@ -0,0 +1,13 @@ +package kr.proxia.core.domain + +import kr.proxia.core.enums.DatabaseEngine +import java.util.UUID + +object DatabaseCredentialGenerator { + fun generatePassword(): String = UUID.randomUUID().toString().replace("-", "") + + fun generateUsername(engine: DatabaseEngine): String = when (engine) { + DatabaseEngine.POSTGRESQL -> "postgres" + else -> "admin" + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseEngineConfig.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseEngineConfig.kt new file mode 100644 index 0000000..438d99d --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseEngineConfig.kt @@ -0,0 +1,54 @@ +package kr.proxia.core.domain + +import kr.proxia.core.enums.DatabaseEngine + +enum class DatabaseEngineConfig( + val image: String, + val dataPath: String, + val supportedVersions: List, +) { + MYSQL("mysql", "/var/lib/mysql", listOf("8.0", "8.4", "9.0", "9.1")), + POSTGRESQL("postgres", "/var/lib/postgresql/data", listOf("14", "15", "16", "17")), + REDIS("redis", "/data", listOf("7.0", "7.2", "7.4", "8.0")), + ; + + fun env( + database: String, + username: String, + password: String, + ): List = + when (this) { + MYSQL -> + listOf( + "MYSQL_DATABASE=$database", + "MYSQL_USER=$username", + "MYSQL_PASSWORD=$password", + "MYSQL_ROOT_PASSWORD=$password", + ) + + POSTGRESQL -> + listOf( + "POSTGRES_DB=$database", + "POSTGRES_USER=$username", + "POSTGRES_PASSWORD=$password", + ) + + REDIS -> emptyList() + } + + fun commandArgs(password: String): List = + when (this) { + REDIS -> + if (password.isNotBlank()) { + listOf("redis-server", "--requirepass", password) + } else { + emptyList() + } + + else -> emptyList() + } + + companion object { + fun of(engine: DatabaseEngine) = valueOf(engine.name) + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt new file mode 100644 index 0000000..d7a79ba --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt @@ -0,0 +1,81 @@ +package kr.proxia.core.domain + +import kr.proxia.client.git.github.GitHubConnectionClient +import kr.proxia.core.enums.GitProvider +import kr.proxia.core.support.error.CoreException +import kr.proxia.core.support.error.ErrorType +import kr.proxia.storage.db.core.entity.GitConnection +import kr.proxia.storage.db.core.repository.GitConnectionRepository +import kr.proxia.storage.db.core.repository.WorkspaceRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class GitConnectionService( + private val workspaceRepository: WorkspaceRepository, + private val gitConnectionRepository: GitConnectionRepository, + private val githubConnectionClient: GitHubConnectionClient, +) { + fun getConnections( + userId: UUID, + workspaceId: UUID, + ): List { + val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + return gitConnectionRepository.findAllByWorkspace(workspace) + } + + fun createConnection( + userId: UUID, + workspaceId: UUID, + createGitConnection: CreateGitConnection, + ) { + val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + if (gitConnectionRepository.existsByWorkspaceAndInstallationId(workspace, createGitConnection.installationId)) { + throw CoreException(ErrorType.GIT_CONNECTION_ALREADY_EXISTS) + } + + val connection = + when (createGitConnection.provider) { + GitProvider.GITHUB -> { + val response = githubConnectionClient.getInstallationToken(createGitConnection.installationId) + + GitConnection( + workspace = workspace, + name = createGitConnection.name, + provider = GitProvider.GITHUB, + installationId = createGitConnection.installationId, + accessToken = response.token, + expiresAt = response.expiresAt, + ) + } + + else -> throw CoreException(ErrorType.UNSUPPORTED_GIT_PROVIDER) + } + + gitConnectionRepository.save(connection) + } + + fun deleteConnection( + userId: UUID, + workspaceId: UUID, + connectionId: UUID, + ) { + if (!workspaceRepository.existsByIdAndMember(workspaceId, userId)) { + throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + } + + val connection = gitConnectionRepository.findByIdOrNull(connectionId) + ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) + + if (connection.workspace.id != workspaceId) { + throw CoreException(ErrorType.ACCESS_DENIED) + } + + gitConnectionRepository.delete(connection) + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepository.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepository.kt new file mode 100644 index 0000000..5286ab2 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepository.kt @@ -0,0 +1,5 @@ +package kr.proxia.core.domain + +data class GitRepository( + val name: String, +) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt new file mode 100644 index 0000000..4e1af5d --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt @@ -0,0 +1,36 @@ +package kr.proxia.core.domain + +import kr.proxia.client.git.github.GitHubConnectionClient +import kr.proxia.client.git.github.GitHubRepositoryClient +import kr.proxia.core.support.error.CoreException +import kr.proxia.core.support.error.ErrorType +import kr.proxia.storage.db.core.repository.GitConnectionRepository +import kr.proxia.storage.db.core.repository.WorkspaceRepository +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class GitRepositoryService( + private val workspaceRepository: WorkspaceRepository, + private val gitConnectionRepository: GitConnectionRepository, + private val githubConnectionClient: GitHubConnectionClient, + private val githubRepositoryClient: GitHubRepositoryClient, +) { + fun getRepositories( + userId: UUID, + workspaceId: UUID, + connectionId: UUID, + ): List { + val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + val connection = gitConnectionRepository.findByIdAndWorkspace(connectionId, workspace) + ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) + + val tokenResponse = githubConnectionClient.getInstallationToken(connection.installationId) + + return githubRepositoryClient.getGithubRepositories(tokenResponse.token).map { + GitRepository(name = it.fullName) + } + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt new file mode 100644 index 0000000..62389d3 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt @@ -0,0 +1,134 @@ +package kr.proxia.core.domain + +import kr.proxia.client.git.github.GitHubConnectionClient +import kr.proxia.client.git.github.GitHubRepositoryClient +import kr.proxia.core.enums.ServiceStatus +import kr.proxia.core.support.error.CoreException +import kr.proxia.core.support.error.ErrorType +import kr.proxia.storage.db.core.entity.AppService +import kr.proxia.storage.db.core.entity.DatabaseService +import kr.proxia.storage.db.core.repository.GitConnectionRepository +import kr.proxia.storage.db.core.entity.Service as ServiceEntity +import kr.proxia.storage.db.core.repository.ProjectRepository +import kr.proxia.storage.db.core.repository.ServiceRepository +import kr.proxia.storage.db.core.repository.WorkspaceRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class ServiceService( + private val workspaceRepository: WorkspaceRepository, + private val projectRepository: ProjectRepository, + private val serviceRepository: ServiceRepository, + private val gitConnectionRepository: GitConnectionRepository, + private val githubConnectionClient: GitHubConnectionClient, + private val githubRepositoryClient: GitHubRepositoryClient, +) { + fun getServices( + userId: UUID, + workspaceId: UUID, + projectId: UUID, + ): List { + val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + val project = projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + + return serviceRepository.findAllByProject(project) + } + + fun getService( + userId: UUID, + workspaceId: UUID, + projectId: UUID, + serviceId: UUID, + ): ServiceEntity { + val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + val project = projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + + return serviceRepository.findByIdAndProject(serviceId, project) + ?: throw CoreException(ErrorType.SERVICE_NOT_FOUND) + } + + @Transactional + fun createAppService( + userId: UUID, + workspaceId: UUID, + projectId: UUID, + createAppService: CreateAppService, + ): ServiceEntity { + val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + val project = projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + + val gitConnection = gitConnectionRepository.findByIdAndWorkspace(createAppService.gitConnectionId, workspace) + ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) + + val tokenResponse = githubConnectionClient.getInstallationToken(gitConnection.installationId) + + val hasAccess = githubRepositoryClient.verifyRepositoryAccess( + tokenResponse.token, + createAppService.repoFullName, + ) + + if (!hasAccess) { + throw CoreException(ErrorType.GIT_REPOSITORY_NOT_FOUND) + } + + val service = AppService( + name = createAppService.name, + x = createAppService.x, + y = createAppService.y, + project = project, + status = ServiceStatus.STARTING, + repoFullName = createAppService.repoFullName, + branch = createAppService.branch ?: "main", + port = createAppService.framework.defaultPort, + framework = createAppService.framework, + rootDirectory = ".", + buildCommand = createAppService.buildCommand, + startCommand = createAppService.startCommand, + gitConnection = gitConnection, + ) + + return serviceRepository.save(service) + } + + @Transactional + fun createDatabaseService( + userId: UUID, + workspaceId: UUID, + projectId: UUID, + createDatabaseService: CreateDatabaseService, + ): ServiceEntity { + val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + val project = projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + + val engine = createDatabaseService.engine + + val service = DatabaseService( + name = createDatabaseService.name, + x = createDatabaseService.x, + y = createDatabaseService.y, + project = project, + status = ServiceStatus.STARTING, + engine = engine, + version = DatabaseEngineConfig.of(engine).supportedVersions.last(), + database = project.name.replace("-", "_"), + username = DatabaseCredentialGenerator.generateUsername(engine), + password = DatabaseCredentialGenerator.generatePassword(), + ) + + return serviceRepository.save(service) + } +} diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt b/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt index 27bd092..686b548 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt @@ -15,6 +15,7 @@ enum class ErrorType( INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid token"), TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "Token expired"), FORBIDDEN(HttpStatus.FORBIDDEN, "Access denied"), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "Access denied"), OAUTH_FAILED(HttpStatus.BAD_REQUEST, "OAuth authentication failed"), EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "No verified email found"), @@ -22,4 +23,12 @@ enum class ErrorType( USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found"), WORKSPACE_NOT_FOUND(HttpStatus.NOT_FOUND, "Workspace not found"), PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "Project not found"), + SERVICE_NOT_FOUND(HttpStatus.NOT_FOUND, "Service not found"), + GIT_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "Git connection not found"), + GIT_REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "Git repository not found"), + + GIT_CONNECTION_ALREADY_EXISTS(HttpStatus.CONFLICT, "Git connection already exists"), + + UNSUPPORTED_SERVICE_TYPE(HttpStatus.INTERNAL_SERVER_ERROR, "Service type not supported"), + UNSUPPORTED_GIT_PROVIDER(HttpStatus.INTERNAL_SERVER_ERROR, "Git provider not supported"), } diff --git a/settings.gradle.kts b/settings.gradle.kts index 98f344c..52466ef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,3 +33,4 @@ include("tests:api-docs") include("clients:client-docker") include("clients:client-oauth") include("clients:client-aws") +include("clients:client-git") diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/GitConnection.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/GitConnection.kt index 45c0956..a449f24 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/GitConnection.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/GitConnection.kt @@ -19,6 +19,7 @@ class GitConnection( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "workspace_id", nullable = false) val workspace: Workspace, + val name: String, @Enumerated(EnumType.STRING) @Column(nullable = false) val provider: GitProvider, diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt index 3d604e4..6a0d6aa 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt @@ -7,4 +7,8 @@ import java.util.UUID interface GitConnectionRepository : JpaRepository { fun findAllByWorkspace(workspace: Workspace): List + + fun findByIdAndWorkspace(id: UUID, workspace: Workspace): GitConnection? + + fun existsByWorkspaceAndInstallationId(workspace: Workspace, installationId: String): Boolean } diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/ServiceRepository.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/ServiceRepository.kt index 653071d..5f77e25 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/ServiceRepository.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/ServiceRepository.kt @@ -1,7 +1,15 @@ package kr.proxia.storage.db.core.repository +import kr.proxia.storage.db.core.entity.Project import kr.proxia.storage.db.core.entity.Service import org.springframework.data.jpa.repository.JpaRepository import java.util.UUID -interface ServiceRepository : JpaRepository +interface ServiceRepository : JpaRepository { + fun findByIdAndProject( + id: UUID, + project: Project, + ): Service? + + fun findAllByProject(project: Project): List +} diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt index 7e6cc27..be88c95 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt @@ -33,4 +33,19 @@ interface WorkspaceRepository : JpaRepository { workspaceId: UUID, userId: UUID, ): Workspace? + + + @Query( + """ + SELECT COUNT(w) > 0 + FROM Workspace w + JOIN WorkspaceMember wm ON wm.workspace = w + WHERE w.id = :workspaceId + AND wm.user.id = :userId + """, + ) + fun existsByIdAndMember( + workspaceId: UUID, + userId: UUID, + ): Boolean } From 062903f6d8eed5d2be1d2cfb57943b9c303b0387 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:22:42 +0900 Subject: [PATCH 02/22] style: apply ktlint format --- .../client/git/github/GitHubAppJwtProvider.kt | 21 ++-- .../git/github/GitHubConnectionClient.kt | 3 +- .../git/github/GitHubRepositoriesResponse.kt | 2 +- .../client/git/github/GitHubRepository.kt | 2 +- .../git/github/GitHubRepositoryClient.kt | 14 ++- .../controller/v1/GitConnectionController.kt | 9 +- .../controller/v1/GitRepositoryController.kt | 11 +- .../api/controller/v1/ServiceController.kt | 1 - .../v1/request/CreateGitConnectionRequest.kt | 11 +- .../v1/response/ServiceTypeResponse.kt | 18 +-- .../domain/DatabaseCredentialGenerator.kt | 9 +- .../core/domain/GitConnectionService.kt | 15 ++- .../core/domain/GitRepositoryService.kt | 10 +- .../kr/proxia/core/domain/ServiceService.kt | 112 ++++++++++-------- .../repository/GitConnectionRepository.kt | 10 +- .../db/core/repository/WorkspaceRepository.kt | 1 - 16 files changed, 144 insertions(+), 105 deletions(-) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt index afc37c6..3c28a59 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt @@ -10,12 +10,16 @@ import java.util.Date @Component class GitHubAppJwtProvider { - fun createAsyncJwt(appId: String, privateKeyPem: String): String { + fun createAsyncJwt( + appId: String, + privateKeyPem: String, + ): String { val now = System.currentTimeMillis() val issuedAt = Date(now - 60_000) val expiration = Date(now + 600_000) - return Jwts.builder() + return Jwts + .builder() .issuer(appId) .issuedAt(issuedAt) .expiration(expiration) @@ -24,12 +28,13 @@ class GitHubAppJwtProvider { } private fun decodePrivateKey(pem: String): PrivateKey { - val cleanPem = pem - .replace("-----BEGIN RSA PRIVATE KEY-----", "") - .replace("-----END RSA PRIVATE KEY-----", "") - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("\\s".toRegex(), "") + val cleanPem = + pem + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") val decoded = Base64.getDecoder().decode(cleanPem) val spec = PKCS8EncodedKeySpec(decoded) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt index 8a6af71..a5ba89e 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt @@ -14,7 +14,8 @@ class GitHubConnectionClient( fun getInstallationToken(installationId: String): InstallationTokenResponse { val jwt = jwtProvider.createAsyncJwt(properties.github.appId, properties.github.privateKey) - return githubRestClient.post() + return githubRestClient + .post() .uri("https://api.github.com/app/installations/$installationId/access_tokens") .header("Authorization", "Bearer $jwt") .header("Accept", "application/vnd.github+json") diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt index 7d20cb3..4738152 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt @@ -5,5 +5,5 @@ import com.fasterxml.jackson.annotation.JsonProperty data class GitHubRepositoriesResponse( @JsonProperty("total_count") val totalCount: Int, - val repositories: List + val repositories: List, ) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt index dbb76a7..a003845 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepository.kt @@ -11,5 +11,5 @@ data class GitHubRepository( val defaultBranch: String, val private: Boolean, @JsonProperty("html_url") - val htmlUrl: String + val htmlUrl: String, ) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt index 37c869b..3e4c6b8 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt @@ -9,16 +9,22 @@ class GitHubRepositoryClient( private val githubRestClient: RestClient, ) { fun getGithubRepositories(accessToken: String): List = - githubRestClient.get() + githubRestClient + .get() .uri("https://api.github.com/installation/repositories") .header("Authorization", "Bearer $accessToken") .header("Accept", "application/vnd.github+json") .retrieve() - .body()!!.repositories + .body()!! + .repositories - fun verifyRepositoryAccess(token: String, repoFullName: String): Boolean = + fun verifyRepositoryAccess( + token: String, + repoFullName: String, + ): Boolean = try { - githubRestClient.get() + githubRestClient + .get() .uri("https://api.github.com/repos/$repoFullName") .header("Authorization", "Bearer $token") .header("Accept", "application/vnd.github+json") diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt index 1b63c71..4a07136 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt @@ -22,10 +22,11 @@ class GitConnectionController( @AuthenticationPrincipal userId: UUID, @PathVariable workspaceId: UUID, ): List { - val connections = gitConnectionService.getConnections( - userId = userId, - workspaceId = workspaceId, - ) + val connections = + gitConnectionService.getConnections( + userId = userId, + workspaceId = workspaceId, + ) return connections.map { GitConnectionResponse( diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt index 8b188fd..2fbc1b3 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt @@ -20,11 +20,12 @@ class GitRepositoryController( @PathVariable workspaceId: UUID, @PathVariable connectionId: UUID, ): List { - val repositories = gitRepositoryService.getRepositories( - userId = userId, - workspaceId = workspaceId, - connectionId = connectionId, - ) + val repositories = + gitRepositoryService.getRepositories( + userId = userId, + workspaceId = workspaceId, + connectionId = connectionId, + ) return repositories.map { GitRepositoryResponse( diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt index 0beecd1..8fd625a 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt @@ -4,7 +4,6 @@ import kr.proxia.core.api.controller.v1.request.CreateAppServiceRequest import kr.proxia.core.api.controller.v1.request.CreateDatabaseServiceRequest import kr.proxia.core.api.controller.v1.response.ServiceDetailResponse import kr.proxia.core.api.controller.v1.response.ServiceResponse -import kr.proxia.core.api.controller.v1.response.ServiceTypeResponse import kr.proxia.core.domain.ServiceService import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt index 6fc32f1..ab3d2d0 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateGitConnectionRequest.kt @@ -8,9 +8,10 @@ data class CreateGitConnectionRequest( val name: String, val provider: GitProvider, ) { - fun toDomain(): CreateGitConnection = CreateGitConnection( - installationId = installationId, - name = name, - provider = provider, - ) + fun toDomain(): CreateGitConnection = + CreateGitConnection( + installationId = installationId, + name = name, + provider = provider, + ) } diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt index 8fb611d..309691b 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceTypeResponse.kt @@ -13,15 +13,17 @@ data class ServiceTypeResponse( companion object { fun from(service: Service) = when (service) { - is AppService -> ServiceTypeResponse( - category = "app", - techStack = service.framework.name, - ) + is AppService -> + ServiceTypeResponse( + category = "app", + techStack = service.framework.name, + ) - is DatabaseService -> ServiceTypeResponse( - category = "database", - techStack = service.engine.name, - ) + is DatabaseService -> + ServiceTypeResponse( + category = "database", + techStack = service.engine.name, + ) else -> throw CoreException(ErrorType.UNSUPPORTED_SERVICE_TYPE) } diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt index ea5b119..bc5127c 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/DatabaseCredentialGenerator.kt @@ -6,8 +6,9 @@ import java.util.UUID object DatabaseCredentialGenerator { fun generatePassword(): String = UUID.randomUUID().toString().replace("-", "") - fun generateUsername(engine: DatabaseEngine): String = when (engine) { - DatabaseEngine.POSTGRESQL -> "postgres" - else -> "admin" - } + fun generateUsername(engine: DatabaseEngine): String = + when (engine) { + DatabaseEngine.POSTGRESQL -> "postgres" + else -> "admin" + } } diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt index d7a79ba..8171600 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt @@ -21,8 +21,9 @@ class GitConnectionService( userId: UUID, workspaceId: UUID, ): List { - val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) - ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) return gitConnectionRepository.findAllByWorkspace(workspace) } @@ -32,8 +33,9 @@ class GitConnectionService( workspaceId: UUID, createGitConnection: CreateGitConnection, ) { - val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) - ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) if (gitConnectionRepository.existsByWorkspaceAndInstallationId(workspace, createGitConnection.installationId)) { throw CoreException(ErrorType.GIT_CONNECTION_ALREADY_EXISTS) @@ -69,8 +71,9 @@ class GitConnectionService( throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) } - val connection = gitConnectionRepository.findByIdOrNull(connectionId) - ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) + val connection = + gitConnectionRepository.findByIdOrNull(connectionId) + ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) if (connection.workspace.id != workspaceId) { throw CoreException(ErrorType.ACCESS_DENIED) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt index 4e1af5d..704b0df 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt @@ -21,11 +21,13 @@ class GitRepositoryService( workspaceId: UUID, connectionId: UUID, ): List { - val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) - ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) - val connection = gitConnectionRepository.findByIdAndWorkspace(connectionId, workspace) - ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) + val connection = + gitConnectionRepository.findByIdAndWorkspace(connectionId, workspace) + ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) val tokenResponse = githubConnectionClient.getInstallationToken(connection.installationId) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt index 62389d3..fde72b3 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt @@ -8,13 +8,13 @@ import kr.proxia.core.support.error.ErrorType import kr.proxia.storage.db.core.entity.AppService import kr.proxia.storage.db.core.entity.DatabaseService import kr.proxia.storage.db.core.repository.GitConnectionRepository -import kr.proxia.storage.db.core.entity.Service as ServiceEntity import kr.proxia.storage.db.core.repository.ProjectRepository import kr.proxia.storage.db.core.repository.ServiceRepository import kr.proxia.storage.db.core.repository.WorkspaceRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID +import kr.proxia.storage.db.core.entity.Service as ServiceEntity @Service class ServiceService( @@ -30,11 +30,13 @@ class ServiceService( workspaceId: UUID, projectId: UUID, ): List { - val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) - ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) - val project = projectRepository.findByIdAndWorkspace(projectId, workspace) - ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + val project = + projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) return serviceRepository.findAllByProject(project) } @@ -45,11 +47,13 @@ class ServiceService( projectId: UUID, serviceId: UUID, ): ServiceEntity { - val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) - ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) - val project = projectRepository.findByIdAndWorkspace(projectId, workspace) - ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + val project = + projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) return serviceRepository.findByIdAndProject(serviceId, project) ?: throw CoreException(ErrorType.SERVICE_NOT_FOUND) @@ -62,41 +66,46 @@ class ServiceService( projectId: UUID, createAppService: CreateAppService, ): ServiceEntity { - val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) - ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) - val project = projectRepository.findByIdAndWorkspace(projectId, workspace) - ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + val project = + projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) - val gitConnection = gitConnectionRepository.findByIdAndWorkspace(createAppService.gitConnectionId, workspace) - ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) + val gitConnection = + gitConnectionRepository.findByIdAndWorkspace(createAppService.gitConnectionId, workspace) + ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) val tokenResponse = githubConnectionClient.getInstallationToken(gitConnection.installationId) - val hasAccess = githubRepositoryClient.verifyRepositoryAccess( - tokenResponse.token, - createAppService.repoFullName, - ) + val hasAccess = + githubRepositoryClient.verifyRepositoryAccess( + tokenResponse.token, + createAppService.repoFullName, + ) if (!hasAccess) { throw CoreException(ErrorType.GIT_REPOSITORY_NOT_FOUND) } - val service = AppService( - name = createAppService.name, - x = createAppService.x, - y = createAppService.y, - project = project, - status = ServiceStatus.STARTING, - repoFullName = createAppService.repoFullName, - branch = createAppService.branch ?: "main", - port = createAppService.framework.defaultPort, - framework = createAppService.framework, - rootDirectory = ".", - buildCommand = createAppService.buildCommand, - startCommand = createAppService.startCommand, - gitConnection = gitConnection, - ) + val service = + AppService( + name = createAppService.name, + x = createAppService.x, + y = createAppService.y, + project = project, + status = ServiceStatus.STARTING, + repoFullName = createAppService.repoFullName, + branch = createAppService.branch ?: "main", + port = createAppService.framework.defaultPort, + framework = createAppService.framework, + rootDirectory = ".", + buildCommand = createAppService.buildCommand, + startCommand = createAppService.startCommand, + gitConnection = gitConnection, + ) return serviceRepository.save(service) } @@ -108,26 +117,29 @@ class ServiceService( projectId: UUID, createDatabaseService: CreateDatabaseService, ): ServiceEntity { - val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) - ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) - val project = projectRepository.findByIdAndWorkspace(projectId, workspace) - ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + val project = + projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) val engine = createDatabaseService.engine - val service = DatabaseService( - name = createDatabaseService.name, - x = createDatabaseService.x, - y = createDatabaseService.y, - project = project, - status = ServiceStatus.STARTING, - engine = engine, - version = DatabaseEngineConfig.of(engine).supportedVersions.last(), - database = project.name.replace("-", "_"), - username = DatabaseCredentialGenerator.generateUsername(engine), - password = DatabaseCredentialGenerator.generatePassword(), - ) + val service = + DatabaseService( + name = createDatabaseService.name, + x = createDatabaseService.x, + y = createDatabaseService.y, + project = project, + status = ServiceStatus.STARTING, + engine = engine, + version = DatabaseEngineConfig.of(engine).supportedVersions.last(), + database = project.name.replace("-", "_"), + username = DatabaseCredentialGenerator.generateUsername(engine), + password = DatabaseCredentialGenerator.generatePassword(), + ) return serviceRepository.save(service) } diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt index 6a0d6aa..555879f 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/GitConnectionRepository.kt @@ -8,7 +8,13 @@ import java.util.UUID interface GitConnectionRepository : JpaRepository { fun findAllByWorkspace(workspace: Workspace): List - fun findByIdAndWorkspace(id: UUID, workspace: Workspace): GitConnection? + fun findByIdAndWorkspace( + id: UUID, + workspace: Workspace, + ): GitConnection? - fun existsByWorkspaceAndInstallationId(workspace: Workspace, installationId: String): Boolean + fun existsByWorkspaceAndInstallationId( + workspace: Workspace, + installationId: String, + ): Boolean } diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt index be88c95..f8f1793 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/repository/WorkspaceRepository.kt @@ -34,7 +34,6 @@ interface WorkspaceRepository : JpaRepository { userId: UUID, ): Workspace? - @Query( """ SELECT COUNT(w) > 0 From b26c5ee8b4230b79b8fe5788d9c9d58a2da0b967 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:37:00 +0900 Subject: [PATCH 03/22] feat: add functionality to delete service --- .../api/controller/v1/ServiceController.kt | 12 ++++++++-- .../v1/response/ServiceDetailResponse.kt | 4 ++++ .../kr/proxia/core/domain/ServiceService.kt | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt index 8fd625a..1e30f3c 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt @@ -46,7 +46,15 @@ class ServiceController( @PathVariable projectId: UUID, @PathVariable serviceId: UUID, ): ServiceDetailResponse { - TODO() + val service = serviceService.getService(userId, workspaceId, projectId, serviceId) + + return ServiceDetailResponse( + id = service.id, + name = service.name, + x = service.x, + y = service.y, + status = service.status.name, + ) } @PostMapping("/app") @@ -92,6 +100,6 @@ class ServiceController( @PathVariable projectId: UUID, @PathVariable serviceId: UUID, ) { - serviceService.deleteService(userId, projectId, serviceId) + serviceService.deleteService(userId, workspaceId, projectId, serviceId) } } diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt index a390246..9406893 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt @@ -4,4 +4,8 @@ import java.util.UUID data class ServiceDetailResponse( val id: UUID, + val name: String, + val x: Double, + val y: Double, + val status: String, ) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt index fde72b3..58f3c93 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt @@ -143,4 +143,26 @@ class ServiceService( return serviceRepository.save(service) } + + @Transactional + fun deleteService( + userId: UUID, + workspaceId: UUID, + projectId: UUID, + serviceId: UUID, + ) { + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) + + val project = + projectRepository.findByIdAndWorkspace(projectId, workspace) + ?: throw CoreException(ErrorType.PROJECT_NOT_FOUND) + + val service = + serviceRepository.findByIdAndProject(serviceId, project) + ?: throw CoreException(ErrorType.SERVICE_NOT_FOUND) + + serviceRepository.delete(service) + } } From c0c092ba7918e758629a000d4bc8e54c02f09396 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:10:03 +0900 Subject: [PATCH 04/22] feat: add service --- .../src/main/kotlin/kr/proxia/client/git/GitConfig.kt | 2 +- .../kr/proxia/client/git/github/GitHubConnectionClient.kt | 4 ++-- .../kr/proxia/client/git/github/GitHubRepositoryClient.kt | 6 +++--- clients/client-git/src/main/resources/client-git.yml | 4 ++++ core/core-api/src/main/resources/application.yml | 1 + 5 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 clients/client-git/src/main/resources/client-git.yml diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt index 768d94b..37173c3 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt @@ -9,7 +9,7 @@ import org.springframework.web.client.RestClient @EnableConfigurationProperties(GitProperties::class) class GitConfig { @Bean - fun githubRestClient(): RestClient = + fun githubWebRestClient(): RestClient = RestClient .builder() .baseUrl("https://github.com") diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt index a5ba89e..420cc80 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt @@ -7,14 +7,14 @@ import org.springframework.web.client.body @Component class GitHubConnectionClient( - private val githubRestClient: RestClient, + private val githubWebRestClient: RestClient, private val jwtProvider: GitHubAppJwtProvider, private val properties: GitProperties, ) { fun getInstallationToken(installationId: String): InstallationTokenResponse { val jwt = jwtProvider.createAsyncJwt(properties.github.appId, properties.github.privateKey) - return githubRestClient + return githubWebRestClient .post() .uri("https://api.github.com/app/installations/$installationId/access_tokens") .header("Authorization", "Bearer $jwt") diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt index 3e4c6b8..7b70c2e 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt @@ -6,10 +6,10 @@ import org.springframework.web.client.body @Component class GitHubRepositoryClient( - private val githubRestClient: RestClient, + private val githubWebRestClient: RestClient, ) { fun getGithubRepositories(accessToken: String): List = - githubRestClient + githubWebRestClient .get() .uri("https://api.github.com/installation/repositories") .header("Authorization", "Bearer $accessToken") @@ -23,7 +23,7 @@ class GitHubRepositoryClient( repoFullName: String, ): Boolean = try { - githubRestClient + githubWebRestClient .get() .uri("https://api.github.com/repos/$repoFullName") .header("Authorization", "Bearer $token") diff --git a/clients/client-git/src/main/resources/client-git.yml b/clients/client-git/src/main/resources/client-git.yml new file mode 100644 index 0000000..c30654a --- /dev/null +++ b/clients/client-git/src/main/resources/client-git.yml @@ -0,0 +1,4 @@ +git: + github: + app-id: ${GITHUB_APP_ID:} + private-key: ${GITHUB_PRIVATE_KEY:} diff --git a/core/core-api/src/main/resources/application.yml b/core/core-api/src/main/resources/application.yml index a95f577..bfd8776 100644 --- a/core/core-api/src/main/resources/application.yml +++ b/core/core-api/src/main/resources/application.yml @@ -11,6 +11,7 @@ spring: - security.yml - client-oauth.yml - client-aws.yml + - client-git.yml web: resources: add-mappings: false From 2bea435e855df8dce1928af9a8ab0c0f6c267ff7 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:32:13 +0900 Subject: [PATCH 05/22] refactor: change endpoint method from POST to DELETE --- .../proxia/core/api/controller/v1/GitConnectionController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt index 4a07136..a9dc70b 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitConnectionController.kt @@ -4,6 +4,7 @@ import kr.proxia.core.api.controller.v1.request.CreateGitConnectionRequest import kr.proxia.core.api.controller.v1.response.GitConnectionResponse import kr.proxia.core.domain.GitConnectionService import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -50,7 +51,7 @@ class GitConnectionController( ) } - @PostMapping("/{connectionId}") + @DeleteMapping("/{connectionId}") fun deleteConnection( @AuthenticationPrincipal userId: UUID, @PathVariable workspaceId: UUID, From de09664bd628c200a412c22ae427a98dde600563 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:38:12 +0900 Subject: [PATCH 06/22] feat: add request validation using @Valid --- .../kr/proxia/core/api/controller/v1/ProjectController.kt | 3 ++- .../kr/proxia/core/api/controller/v1/ServiceController.kt | 5 +++-- .../kr/proxia/core/api/controller/v1/WorkspaceController.kt | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ProjectController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ProjectController.kt index 9afb5c8..9a7364b 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ProjectController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ProjectController.kt @@ -1,5 +1,6 @@ package kr.proxia.core.api.controller.v1 +import jakarta.validation.Valid import kr.proxia.core.api.controller.v1.request.CreateProjectRequest import kr.proxia.core.api.controller.v1.response.ProjectDetailResponse import kr.proxia.core.api.controller.v1.response.ProjectResponse @@ -57,7 +58,7 @@ class ProjectController( fun createProject( @AuthenticationPrincipal userId: UUID, @PathVariable workspaceId: UUID, - @RequestBody request: CreateProjectRequest, + @Valid @RequestBody request: CreateProjectRequest, ): ProjectResponse { val project = projectService.createProject(userId, workspaceId, request.toDomain()) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt index 1e30f3c..33629be 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt @@ -1,5 +1,6 @@ package kr.proxia.core.api.controller.v1 +import jakarta.validation.Valid import kr.proxia.core.api.controller.v1.request.CreateAppServiceRequest import kr.proxia.core.api.controller.v1.request.CreateDatabaseServiceRequest import kr.proxia.core.api.controller.v1.response.ServiceDetailResponse @@ -62,7 +63,7 @@ class ServiceController( @AuthenticationPrincipal userId: UUID, @PathVariable workspaceId: UUID, @PathVariable projectId: UUID, - @RequestBody request: CreateAppServiceRequest, + @Valid @RequestBody request: CreateAppServiceRequest, ): ServiceResponse { val service = serviceService.createAppService(userId, workspaceId, projectId, request.toDomain()) @@ -80,7 +81,7 @@ class ServiceController( @AuthenticationPrincipal userId: UUID, @PathVariable workspaceId: UUID, @PathVariable projectId: UUID, - @RequestBody request: CreateDatabaseServiceRequest, + @Valid @RequestBody request: CreateDatabaseServiceRequest, ): ServiceResponse { val service = serviceService.createDatabaseService(userId, workspaceId, projectId, request.toDomain()) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/WorkspaceController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/WorkspaceController.kt index 41b72eb..f9a2851 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/WorkspaceController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/WorkspaceController.kt @@ -1,5 +1,6 @@ package kr.proxia.core.api.controller.v1 +import jakarta.validation.Valid import kr.proxia.core.api.controller.v1.request.CreateWorkspaceRequest import kr.proxia.core.api.controller.v1.response.WorkspaceDetailResponse import kr.proxia.core.api.controller.v1.response.WorkspaceMemberResponse @@ -67,7 +68,7 @@ class WorkspaceController( @PostMapping fun createWorkspace( @AuthenticationPrincipal userId: UUID, - @RequestBody request: CreateWorkspaceRequest, + @Valid @RequestBody request: CreateWorkspaceRequest, ): WorkspaceResponse { val workspace = workspaceService.createWorkspace(userId, request.toDomain()) From 56cac9055f3927aef93c73da58adde88b7b5a14b Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:40:45 +0900 Subject: [PATCH 07/22] fix: apply provided port on AppService creation --- .../src/main/kotlin/kr/proxia/core/domain/ServiceService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt index 58f3c93..e1fc860 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/ServiceService.kt @@ -99,7 +99,7 @@ class ServiceService( status = ServiceStatus.STARTING, repoFullName = createAppService.repoFullName, branch = createAppService.branch ?: "main", - port = createAppService.framework.defaultPort, + port = createAppService.port ?: createAppService.framework.defaultPort, framework = createAppService.framework, rootDirectory = ".", buildCommand = createAppService.buildCommand, From 41f160e64d1c1aac8bc9bdc63101e40761e7467b Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:35:52 +0900 Subject: [PATCH 08/22] refactor: rename createAsyncJwt to createJwt --- .../kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt index 3c28a59..0b237d6 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubAppJwtProvider.kt @@ -10,7 +10,7 @@ import java.util.Date @Component class GitHubAppJwtProvider { - fun createAsyncJwt( + fun createJwt( appId: String, privateKeyPem: String, ): String { From 8ed53fc14071e79c6939ae22befb88126347d83c Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:36:49 +0900 Subject: [PATCH 09/22] refactor: replace absolute GitHub API URL with baseUrl --- .../src/main/kotlin/kr/proxia/client/git/GitConfig.kt | 2 +- .../kr/proxia/client/git/github/GitHubConnectionClient.kt | 4 ++-- .../kr/proxia/client/git/github/GitHubRepositoryClient.kt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt index 37173c3..2d8ad84 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/GitConfig.kt @@ -12,6 +12,6 @@ class GitConfig { fun githubWebRestClient(): RestClient = RestClient .builder() - .baseUrl("https://github.com") + .baseUrl("https://api.github.com") .build() } diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt index 420cc80..94ee99e 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubConnectionClient.kt @@ -12,11 +12,11 @@ class GitHubConnectionClient( private val properties: GitProperties, ) { fun getInstallationToken(installationId: String): InstallationTokenResponse { - val jwt = jwtProvider.createAsyncJwt(properties.github.appId, properties.github.privateKey) + val jwt = jwtProvider.createJwt(properties.github.appId, properties.github.privateKey) return githubWebRestClient .post() - .uri("https://api.github.com/app/installations/$installationId/access_tokens") + .uri("/app/installations/$installationId/access_tokens") .header("Authorization", "Bearer $jwt") .header("Accept", "application/vnd.github+json") .retrieve() diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt index 7b70c2e..ca5f50a 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt @@ -11,7 +11,7 @@ class GitHubRepositoryClient( fun getGithubRepositories(accessToken: String): List = githubWebRestClient .get() - .uri("https://api.github.com/installation/repositories") + .uri("/installation/repositories") .header("Authorization", "Bearer $accessToken") .header("Accept", "application/vnd.github+json") .retrieve() @@ -25,7 +25,7 @@ class GitHubRepositoryClient( try { githubWebRestClient .get() - .uri("https://api.github.com/repos/$repoFullName") + .uri("/repos/$repoFullName") .header("Authorization", "Bearer $token") .header("Accept", "application/vnd.github+json") .retrieve() From d760214b511443cf697b1daba0040135a2d11001 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:37:58 +0900 Subject: [PATCH 10/22] refactor: remove duplicate forbidden/access denied error type --- .../src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt b/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt index 46859f6..3813ad1 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/support/error/ErrorType.kt @@ -15,7 +15,6 @@ enum class ErrorType( INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid token"), TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "Token expired"), FORBIDDEN(HttpStatus.FORBIDDEN, "Access denied"), - ACCESS_DENIED(HttpStatus.FORBIDDEN, "Access denied"), OAUTH_FAILED(HttpStatus.BAD_REQUEST, "OAuth authentication failed"), EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "No verified email found"), From 0b1c8d451c64d6b48734c446522d5e173edea371 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:39:12 +0900 Subject: [PATCH 11/22] refactor: optimize delete git connection logic --- .../kr/proxia/core/domain/GitConnectionService.kt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt index 8171600..0ece5ce 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitConnectionService.kt @@ -7,7 +7,6 @@ import kr.proxia.core.support.error.ErrorType import kr.proxia.storage.db.core.entity.GitConnection import kr.proxia.storage.db.core.repository.GitConnectionRepository import kr.proxia.storage.db.core.repository.WorkspaceRepository -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import java.util.UUID @@ -67,18 +66,14 @@ class GitConnectionService( workspaceId: UUID, connectionId: UUID, ) { - if (!workspaceRepository.existsByIdAndMember(workspaceId, userId)) { - throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) - } + val workspace = + workspaceRepository.findByIdAndMember(workspaceId, userId) + ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) val connection = - gitConnectionRepository.findByIdOrNull(connectionId) + gitConnectionRepository.findByIdAndWorkspace(connectionId, workspace) ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) - if (connection.workspace.id != workspaceId) { - throw CoreException(ErrorType.ACCESS_DENIED) - } - gitConnectionRepository.delete(connection) } } From 94ae25f45e9b33b7bc5c15f0b257141ceadf73d8 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:37:35 +0900 Subject: [PATCH 12/22] feat: add page response --- .../kotlin/kr/proxia/core/support/response/PageResponse.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/support/response/PageResponse.kt diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/support/response/PageResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/support/response/PageResponse.kt new file mode 100644 index 0000000..09ca353 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/support/response/PageResponse.kt @@ -0,0 +1,6 @@ +package kr.proxia.core.support.response + +data class PageResponse( + val content: List, + val hasNext: Boolean, +) From 046be6818edaad30922d69cc60301be7bda93cc0 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:38:56 +0900 Subject: [PATCH 13/22] feat: add OffsetLimit for pagination --- .../main/kotlin/kr/proxia/core/support/OffsetLimit.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 core/core-api/src/main/kotlin/kr/proxia/core/support/OffsetLimit.kt diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/support/OffsetLimit.kt b/core/core-api/src/main/kotlin/kr/proxia/core/support/OffsetLimit.kt new file mode 100644 index 0000000..732a018 --- /dev/null +++ b/core/core-api/src/main/kotlin/kr/proxia/core/support/OffsetLimit.kt @@ -0,0 +1,11 @@ +package kr.proxia.core.support + +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable + +data class OffsetLimit( + val offset: Int, + val limit: Int, +) { + fun toPageable(): Pageable = PageRequest.of(offset / limit, limit) +} From ccdb69dd3d5542830865a257be71bfd761364564 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:39:47 +0900 Subject: [PATCH 14/22] refactor: move Page to different package --- .../main/kotlin/kr/proxia/core/support/{response => }/Page.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename core/core-api/src/main/kotlin/kr/proxia/core/support/{response => }/Page.kt (65%) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/support/response/Page.kt b/core/core-api/src/main/kotlin/kr/proxia/core/support/Page.kt similarity index 65% rename from core/core-api/src/main/kotlin/kr/proxia/core/support/response/Page.kt rename to core/core-api/src/main/kotlin/kr/proxia/core/support/Page.kt index 728b9b7..157ccfc 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/support/response/Page.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/support/Page.kt @@ -1,4 +1,4 @@ -package kr.proxia.core.support.response +package kr.proxia.core.support data class Page( val content: List, From 22785d78f04c4c8ba731ca6291538ee33619cda8 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:40:51 +0900 Subject: [PATCH 15/22] feat: add GitHub repository pagination and installation token refresh --- .../github/GitHubRepositoriesApiResponse.kt | 10 +++++ .../git/github/GitHubRepositoriesResponse.kt | 5 +-- .../git/github/GitHubRepositoryClient.kt | 44 ++++++++++++++----- .../controller/v1/GitRepositoryController.kt | 22 +++++++--- .../core/domain/GitRepositoryService.kt | 38 +++++++++++++--- 5 files changed, 94 insertions(+), 25 deletions(-) create mode 100644 clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesApiResponse.kt diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesApiResponse.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesApiResponse.kt new file mode 100644 index 0000000..186f938 --- /dev/null +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesApiResponse.kt @@ -0,0 +1,10 @@ +package kr.proxia.client.git.github + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GitHubRepositoriesApiResponse( + @JsonProperty("total_count") + val totalCount: Int, + @JsonProperty("repositories") + val repositories: List, +) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt index 4738152..09f1d4b 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoriesResponse.kt @@ -1,9 +1,6 @@ package kr.proxia.client.git.github -import com.fasterxml.jackson.annotation.JsonProperty - data class GitHubRepositoriesResponse( - @JsonProperty("total_count") - val totalCount: Int, val repositories: List, + val hasNext: Boolean, ) diff --git a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt index ca5f50a..e83ecb0 100644 --- a/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt +++ b/clients/client-git/src/main/kotlin/kr/proxia/client/git/github/GitHubRepositoryClient.kt @@ -1,22 +1,37 @@ package kr.proxia.client.git.github +import org.springframework.http.HttpHeaders import org.springframework.stereotype.Component import org.springframework.web.client.RestClient -import org.springframework.web.client.body @Component class GitHubRepositoryClient( private val githubWebRestClient: RestClient, ) { - fun getGithubRepositories(accessToken: String): List = - githubWebRestClient - .get() - .uri("/installation/repositories") - .header("Authorization", "Bearer $accessToken") - .header("Accept", "application/vnd.github+json") - .retrieve() - .body()!! - .repositories + fun getGithubRepositories( + accessToken: String, + page: Int, + perPage: Int, + ): GitHubRepositoriesResponse { + val entity = + githubWebRestClient + .get() + .uri { + it + .path("/installation/repositories") + .queryParam("page", page) + .queryParam("per_page", perPage) + .build() + }.header("Authorization", "Bearer $accessToken") + .header("Accept", "application/vnd.github+json") + .retrieve() + .toEntity(GitHubRepositoriesApiResponse::class.java) + + return GitHubRepositoriesResponse( + repositories = entity.body!!.repositories, + hasNext = entity.headers.hasNextPage(), + ) + } fun verifyRepositoryAccess( token: String, @@ -34,4 +49,13 @@ class GitHubRepositoryClient( } catch (_: Exception) { false } + + private fun HttpHeaders.hasNextPage(): Boolean { + val link = getFirst("Link") ?: return false + return NEXT_PAGE_REGEX.containsMatchIn(link) + } + + companion object { + private val NEXT_PAGE_REGEX = Regex("""<([^>]+)>\s*;\s*rel="next"""") + } } diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt index 2fbc1b3..700932b 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryController.kt @@ -2,10 +2,13 @@ package kr.proxia.core.api.controller.v1 import kr.proxia.core.api.controller.v1.response.GitRepositoryResponse import kr.proxia.core.domain.GitRepositoryService +import kr.proxia.core.support.OffsetLimit +import kr.proxia.core.support.response.PageResponse import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.util.UUID @@ -19,18 +22,25 @@ class GitRepositoryController( @AuthenticationPrincipal userId: UUID, @PathVariable workspaceId: UUID, @PathVariable connectionId: UUID, - ): List { + @RequestParam offset: Int, + @RequestParam limit: Int, + ): PageResponse { val repositories = gitRepositoryService.getRepositories( userId = userId, workspaceId = workspaceId, connectionId = connectionId, + offsetLimit = OffsetLimit(offset, limit), ) - return repositories.map { - GitRepositoryResponse( - name = it.name, - ) - } + return PageResponse( + content = + repositories.content.map { + GitRepositoryResponse( + name = it.name, + ) + }, + hasNext = repositories.hasNext, + ) } } diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt index 704b0df..e558092 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/domain/GitRepositoryService.kt @@ -2,11 +2,15 @@ package kr.proxia.core.domain import kr.proxia.client.git.github.GitHubConnectionClient import kr.proxia.client.git.github.GitHubRepositoryClient +import kr.proxia.core.support.OffsetLimit +import kr.proxia.core.support.Page import kr.proxia.core.support.error.CoreException import kr.proxia.core.support.error.ErrorType import kr.proxia.storage.db.core.repository.GitConnectionRepository import kr.proxia.storage.db.core.repository.WorkspaceRepository import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime import java.util.UUID @Service @@ -16,11 +20,13 @@ class GitRepositoryService( private val githubConnectionClient: GitHubConnectionClient, private val githubRepositoryClient: GitHubRepositoryClient, ) { + @Transactional fun getRepositories( userId: UUID, workspaceId: UUID, connectionId: UUID, - ): List { + offsetLimit: OffsetLimit, + ): Page { val workspace = workspaceRepository.findByIdAndMember(workspaceId, userId) ?: throw CoreException(ErrorType.WORKSPACE_NOT_FOUND) @@ -29,10 +35,32 @@ class GitRepositoryService( gitConnectionRepository.findByIdAndWorkspace(connectionId, workspace) ?: throw CoreException(ErrorType.GIT_CONNECTION_NOT_FOUND) - val tokenResponse = githubConnectionClient.getInstallationToken(connection.installationId) + val repositoriesResponse = + githubRepositoryClient.getGithubRepositories( + accessToken = + if (connection.accessToken == null || + connection.expiresAt == null || + connection.expiresAt!!.isBefore(LocalDateTime.now()) + ) { + val token = githubConnectionClient.getInstallationToken(connection.installationId).token + connection.accessToken = token + connection.expiresAt = LocalDateTime.now().plusHours(1L) + token + } else { + connection.accessToken!! + }, + page = (offsetLimit.offset / offsetLimit.limit) + 1, + perPage = offsetLimit.limit, + ) - return githubRepositoryClient.getGithubRepositories(tokenResponse.token).map { - GitRepository(name = it.fullName) - } + return Page( + content = + repositoriesResponse.repositories.map { + GitRepository( + name = it.name, + ) + }, + hasNext = repositoriesResponse.hasNext, + ) } } From 52f00e2a0cb03a4e3ec6a95b353446ddb0e9a144 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:39:33 +0900 Subject: [PATCH 16/22] fix: replace @NotBlank with @NotNull for UUID fields --- .../core/api/controller/v1/request/CreateAppServiceRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt index b6cc12c..2915115 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/request/CreateAppServiceRequest.kt @@ -21,7 +21,7 @@ data class CreateAppServiceRequest( val framework: Framework, val buildCommand: String?, val startCommand: String?, - @field:NotBlank + @field:NotNull val gitConnectionId: UUID, ) { fun toDomain(): CreateAppService = From ed5e030a3c72d44bf5610d51a6f863d5bafb5eee Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:41:32 +0900 Subject: [PATCH 17/22] fix: remove overridden JPA fields in DatabaseService --- .../proxia/storage/db/core/entity/AppService.kt | 12 +++++------- .../storage/db/core/entity/DatabaseService.kt | 15 +++++---------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/AppService.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/AppService.kt index dc07ac1..82adc3e 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/AppService.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/AppService.kt @@ -16,13 +16,11 @@ import kr.proxia.core.enums.ServiceStatus @Table(name = "app_services") @DiscriminatorValue("APP") class AppService( - override val name: String, - override val x: Double, - override val y: Double, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - override val project: Project, - override var status: ServiceStatus, + name: String, + x: Double, + y: Double, + project: Project, + status: ServiceStatus, @Column(nullable = false) val repoFullName: String, @Column(nullable = false) diff --git a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/DatabaseService.kt b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/DatabaseService.kt index 8bc7261..a850331 100644 --- a/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/DatabaseService.kt +++ b/storage/db-core/src/main/kotlin/kr/proxia/storage/db/core/entity/DatabaseService.kt @@ -6,9 +6,6 @@ import jakarta.persistence.DiscriminatorValue import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated -import jakarta.persistence.FetchType -import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne import jakarta.persistence.Table import kr.proxia.core.enums.DatabaseEngine import kr.proxia.core.enums.ServiceStatus @@ -18,13 +15,11 @@ import kr.proxia.storage.db.core.converter.EncryptConverter @Table(name = "database_services") @DiscriminatorValue("DATABASE") class DatabaseService( - override val name: String, - override val x: Double, - override val y: Double, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - override val project: Project, - override var status: ServiceStatus, + name: String, + x: Double, + y: Double, + project: Project, + status: ServiceStatus, @Enumerated(EnumType.STRING) @Column(nullable = false) val engine: DatabaseEngine, From 500736d1e698dcb04a8099c171858afaef70e7af Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:11:36 +0900 Subject: [PATCH 18/22] feat: add createdAt/updatedAt fields --- .../kr/proxia/core/api/controller/v1/ServiceController.kt | 8 ++++++++ .../api/controller/v1/response/ServiceDetailResponse.kt | 3 +++ .../core/api/controller/v1/response/ServiceResponse.kt | 3 +++ 3 files changed, 14 insertions(+) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt index 33629be..69d8c20 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/ServiceController.kt @@ -36,6 +36,8 @@ class ServiceController( x = it.x, y = it.y, status = it.status.name, + createdAt = it.createdAt, + updatedAt = it.updatedAt, ) } } @@ -55,6 +57,8 @@ class ServiceController( x = service.x, y = service.y, status = service.status.name, + createdAt = service.createdAt, + updatedAt = service.updatedAt, ) } @@ -73,6 +77,8 @@ class ServiceController( x = service.x, y = service.y, status = service.status.name, + createdAt = service.createdAt, + updatedAt = service.updatedAt, ) } @@ -91,6 +97,8 @@ class ServiceController( x = service.x, y = service.y, status = service.status.name, + createdAt = service.createdAt, + updatedAt = service.updatedAt, ) } diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt index 9406893..69598b8 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceDetailResponse.kt @@ -1,5 +1,6 @@ package kr.proxia.core.api.controller.v1.response +import java.time.LocalDateTime import java.util.UUID data class ServiceDetailResponse( @@ -8,4 +9,6 @@ data class ServiceDetailResponse( val x: Double, val y: Double, val status: String, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, ) diff --git a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt index cb92030..f7a9f9f 100644 --- a/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt +++ b/core/core-api/src/main/kotlin/kr/proxia/core/api/controller/v1/response/ServiceResponse.kt @@ -1,5 +1,6 @@ package kr.proxia.core.api.controller.v1.response +import java.time.LocalDateTime import java.util.UUID data class ServiceResponse( @@ -8,4 +9,6 @@ data class ServiceResponse( val x: Double, val y: Double, val status: String, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, ) From c67913a1771489bb156923c1132c7098e377cb63 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:48:57 +0900 Subject: [PATCH 19/22] docs: add API documentation for services --- core/core-api/src/docs/asciidoc/index.adoc | 83 ++++ .../v1/ServiceControllerDocsTest.kt | 448 ++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt diff --git a/core/core-api/src/docs/asciidoc/index.adoc b/core/core-api/src/docs/asciidoc/index.adoc index 287384f..f5cabef 100644 --- a/core/core-api/src/docs/asciidoc/index.adoc +++ b/core/core-api/src/docs/asciidoc/index.adoc @@ -196,3 +196,86 @@ include::{snippets}/project-delete/path-parameters.adoc[] .Example include::{snippets}/project-delete/http-request.adoc[] include::{snippets}/project-delete/http-response.adoc[] + +[[service]] +== Service + +서비스 관리 API입니다. 서비스는 프로젝트 하위에 속합니다. + +[[service-list]] +=== 서비스 목록 조회 + +프로젝트 내 서비스 목록을 조회합니다. + +.Path Parameters +include::{snippets}/service-list/path-parameters.adoc[] + +.Response +include::{snippets}/service-list/response-fields.adoc[] + +.Example +include::{snippets}/service-list/http-request.adoc[] +include::{snippets}/service-list/http-response.adoc[] + +[[service-detail]] +=== 서비스 상세 조회 + +서비스 상세 정보를 조회합니다. + +.Path Parameters +include::{snippets}/service-detail/path-parameters.adoc[] + +.Response +include::{snippets}/service-detail/response-fields.adoc[] + +.Example +include::{snippets}/service-detail/http-request.adoc[] +include::{snippets}/service-detail/http-response.adoc[] + +[[service-create-app]] +=== 앱 서비스 생성 + +프로젝트에 새로운 앱 서비스를 생성합니다. + +.Path Parameters +include::{snippets}/service-create-app/path-parameters.adoc[] + +.Request +include::{snippets}/service-create-app/request-fields.adoc[] + +.Response +include::{snippets}/service-create-app/response-fields.adoc[] + +.Example +include::{snippets}/service-create-app/http-request.adoc[] +include::{snippets}/service-create-app/http-response.adoc[] + +[[service-create-database]] +=== 데이터베이스 서비스 생성 + +프로젝트에 새로운 데이터베이스 서비스를 생성합니다. + +.Path Parameters +include::{snippets}/service-create-database/path-parameters.adoc[] + +.Request +include::{snippets}/service-create-database/request-fields.adoc[] + +.Response +include::{snippets}/service-create-database/response-fields.adoc[] + +.Example +include::{snippets}/service-create-database/http-request.adoc[] +include::{snippets}/service-create-database/http-response.adoc[] + +[[service-delete]] +=== 서비스 삭제 + +서비스를 삭제합니다. + +.Path Parameters +include::{snippets}/service-delete/path-parameters.adoc[] + +.Example +include::{snippets}/service-delete/http-request.adoc[] +include::{snippets}/service-delete/http-response.adoc[] diff --git a/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt new file mode 100644 index 0000000..d736999 --- /dev/null +++ b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt @@ -0,0 +1,448 @@ +package kr.proxia.core.api.controller.v1 + +import io.mockk.every +import io.mockk.mockk +import kr.proxia.core.api.controller.v1.request.CreateAppServiceRequest +import kr.proxia.core.api.controller.v1.request.CreateDatabaseServiceRequest +import kr.proxia.core.domain.CreateAppService +import kr.proxia.core.domain.CreateDatabaseService +import kr.proxia.core.domain.ServiceService +import kr.proxia.core.enums.DatabaseEngine +import kr.proxia.core.enums.Framework +import kr.proxia.core.enums.GitProvider +import kr.proxia.core.enums.ServiceStatus +import kr.proxia.storage.db.core.entity.AppService +import kr.proxia.storage.db.core.entity.DatabaseService +import kr.proxia.storage.db.core.entity.GitConnection +import kr.proxia.storage.db.core.entity.Project +import kr.proxia.storage.db.core.entity.Workspace +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.core.MethodParameter +import org.springframework.http.MediaType +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse +import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.requestFields +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import org.springframework.restdocs.request.RequestDocumentation.pathParameters +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import tools.jackson.databind.ObjectMapper +import tools.jackson.module.kotlin.jsonMapper +import tools.jackson.module.kotlin.kotlinModule +import java.util.UUID + +@Tag("restdocs") +@ExtendWith(RestDocumentationExtension::class) +class ServiceControllerDocsTest { + private lateinit var mockMvc: MockMvc + private val objectMapper: ObjectMapper = jsonMapper { addModule(kotlinModule()) } + + private val serviceService = mockk() + private val testUserId: UUID = UUID.randomUUID() + private val testWorkspaceId = UUID.randomUUID() + private val testProjectId = UUID.randomUUID() + private val testGitConnectionId = UUID.randomUUID() + + @BeforeEach + fun setUp(restDocumentation: RestDocumentationContextProvider) { + mockMvc = + MockMvcBuilders + .standaloneSetup(ServiceController(serviceService)) + .setCustomArgumentResolvers(TestAuthenticationPrincipalResolver(testUserId)) + .apply(documentationConfiguration(restDocumentation)) + .build() + } + + @Test + fun `서비스 목록 조회`() { + val workspace = Workspace(name = "Test Workspace") + val project = Project(name = "Project Alpha", subdomain = "alpha", workspace = workspace) + + val gitConnection = + GitConnection( + workspace = workspace, + name = "Test Connection", + provider = GitProvider.GITHUB, + installationId = "test_installation_id", + ) + + val appService = + AppService( + name = "Test App", + x = 0.1, + y = 0.1, + project = project, + status = ServiceStatus.RUNNING, + repoFullName = "test_repo_full_name", + branch = "main", + port = 1234, + framework = Framework.SPRING_BOOT, + rootDirectory = ".", + gitConnection = gitConnection, + ) + + val databaseService = + DatabaseService( + name = "Test Database", + x = 10.0, + y = 10.0, + project = project, + status = ServiceStatus.RUNNING, + engine = DatabaseEngine.MYSQL, + version = "8.0.39", + database = "test_database", + username = "test_username", + password = "test_password", + ) + + every { serviceService.getServices(testUserId, testWorkspaceId, testProjectId) } returns + listOf( + appService, + databaseService, + ) + + mockMvc + .perform( + get( + "/api/v1/workspaces/{workspaceId}/projects/{projectId}/services", + testWorkspaceId, + testProjectId, + ), + ).andExpect(status().isOk) + .andDo( + document( + "service-list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + parameterWithName("projectId").description("Project ID"), + ), + responseFields( + fieldWithPath("[].id").description("Service ID"), + fieldWithPath("[].name").description("Service name"), + fieldWithPath("[].x").description("X coordinate of the service"), + fieldWithPath("[].y").description("Y coordinate of the service"), + fieldWithPath("[].status").description("Service status"), + fieldWithPath("[].createdAt").description("Creation timestamp"), + fieldWithPath("[].updatedAt").description("Last update timestamp"), + ), + ), + ) + } + + @Test + fun `서비스 상세 조회`() { + val workspace = Workspace(name = "Test Workspace") + val project = Project(name = "Project Alpha", subdomain = "alpha", workspace = workspace) + + val gitConnection = + GitConnection( + workspace = workspace, + name = "Test Connection", + provider = GitProvider.GITHUB, + installationId = "test_installation_id", + ) + + val appService = + AppService( + name = "Test App", + x = 0.1, + y = 0.1, + project = project, + status = ServiceStatus.RUNNING, + repoFullName = "test_repo_full_name", + branch = "main", + port = 1234, + framework = Framework.SPRING_BOOT, + rootDirectory = ".", + gitConnection = gitConnection, + ) + + every { + serviceService.getService( + testUserId, + testWorkspaceId, + testProjectId, + appService.id, + ) + } returns appService + + mockMvc + .perform( + get( + "/api/v1/workspaces/{workspaceId}/projects/{projectId}/services/{serviceId}", + testWorkspaceId, + testProjectId, + appService.id, + ), + ).andExpect(status().isOk) + .andDo( + document( + "service-detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + parameterWithName("projectId").description("Project ID"), + parameterWithName("serviceId").description("Service ID"), + ), + responseFields( + fieldWithPath("id").description("Service ID"), + fieldWithPath("name").description("Service name"), + fieldWithPath("x").description("X coordinate of the service"), + fieldWithPath("y").description("Y coordinate of the service"), + fieldWithPath("status").description("Service status"), + fieldWithPath("createdAt").description("Creation timestamp"), + fieldWithPath("updatedAt").description("Last update timestamp"), + ), + ), + ) + } + + @Test + fun `앱 서비스 생성`() { + val workspace = Workspace(name = "Test Workspace") + val project = Project(name = "Project Alpha", subdomain = "alpha", workspace = workspace) + + val gitConnection = + GitConnection( + workspace = workspace, + name = "Test Connection", + provider = GitProvider.GITHUB, + installationId = "test_installation_id", + ) + + val request = + CreateAppServiceRequest( + name = "Test App", + x = 0.1, + y = 0.1, + repoFullName = "test_repo_full_name", + branch = "main", + port = 1234, + framework = Framework.SPRING_BOOT, + buildCommand = "build_command", + startCommand = "start_command", + gitConnectionId = gitConnection.id, + ) + + val appService = + AppService( + name = "Test App", + x = 0.1, + y = 0.1, + project = project, + status = ServiceStatus.RUNNING, + repoFullName = "test_repo_full_name", + branch = "main", + port = 1234, + framework = Framework.SPRING_BOOT, + rootDirectory = ".", + gitConnection = gitConnection, + ) + + every { + serviceService.createAppService( + testUserId, + testWorkspaceId, + testProjectId, + CreateAppService( + name = "Test App", + x = 0.1, + y = 0.1, + repoFullName = "test_repo_full_name", + branch = "main", + port = 1234, + framework = Framework.SPRING_BOOT, + buildCommand = "build_command", + startCommand = "start_command", + gitConnectionId = gitConnection.id, + ), + ) + } returns appService + + mockMvc + .perform( + post( + "/api/v1/workspaces/{workspaceId}/projects/{projectId}/services/app", + testWorkspaceId, + testProjectId, + ).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isOk) + .andDo( + document( + "service-create-app", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + parameterWithName("projectId").description("Project ID"), + ), + requestFields( + fieldWithPath("name").description("Service name"), + fieldWithPath("x").description("X coordinate of the service"), + fieldWithPath("y").description("Y coordinate of the service"), + fieldWithPath("repoFullName").description("Full name of the repository containing the service"), + fieldWithPath("branch").description("Branch of the repository containing the service"), + fieldWithPath("port").description("Port of the service"), + fieldWithPath("framework").description("Framework of the service"), + fieldWithPath("buildCommand").description("Build command of the service"), + fieldWithPath("startCommand").description("Start command of the service"), + fieldWithPath("gitConnectionId").description("ID of git connection containing the repository"), + ), + responseFields( + fieldWithPath("id").description("Service ID"), + fieldWithPath("name").description("Service name"), + fieldWithPath("x").description("X coordinate of the service"), + fieldWithPath("y").description("Y coordinate of the service"), + fieldWithPath("status").description("Service status"), + fieldWithPath("createdAt").description("Creation timestamp"), + fieldWithPath("updatedAt").description("Last update timestamp"), + ), + ), + ) + } + + @Test + fun `데이터베이스 서비스 생성`() { + val workspace = Workspace(name = "Test Workspace") + val project = Project(name = "Project Alpha", subdomain = "alpha", workspace = workspace) + + val request = + CreateDatabaseServiceRequest( + name = "Test Database", + x = 0.1, + y = 0.1, + engine = DatabaseEngine.MYSQL, + ) + + val databaseService = + DatabaseService( + name = "Test Database", + x = 0.1, + y = 0.1, + project = project, + status = ServiceStatus.RUNNING, + engine = DatabaseEngine.MYSQL, + version = "8.0.39", + database = "test_database", + username = "test_user", + password = "test_password", + ) + + every { + serviceService.createDatabaseService( + testUserId, + testWorkspaceId, + testProjectId, + CreateDatabaseService( + name = "Test Database", + x = 0.1, + y = 0.1, + engine = DatabaseEngine.MYSQL, + ), + ) + } returns databaseService + + mockMvc + .perform( + post( + "/api/v1/workspaces/{workspaceId}/projects/{projectId}/services/database", + testWorkspaceId, + testProjectId, + ).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isOk) + .andDo( + document( + "service-create-database", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + parameterWithName("projectId").description("Project ID"), + ), + requestFields( + fieldWithPath("name").description("Service name"), + fieldWithPath("x").description("X coordinate of the service"), + fieldWithPath("y").description("Y coordinate of the service"), + fieldWithPath("engine").description("Engine of the database"), + ), + responseFields( + fieldWithPath("id").description("Service ID"), + fieldWithPath("name").description("Service name"), + fieldWithPath("x").description("X coordinate of the service"), + fieldWithPath("y").description("Y coordinate of the service"), + fieldWithPath("status").description("Service status"), + fieldWithPath("createdAt").description("Creation timestamp"), + fieldWithPath("updatedAt").description("Last update timestamp"), + ), + ), + ) + } + + @Test + fun `서비스 삭제`() { + val serviceId = UUID.randomUUID() + + every { serviceService.deleteService(testUserId, testWorkspaceId, testProjectId, serviceId) } returns Unit + + mockMvc + .perform( + delete( + "/api/v1/workspaces/{workspaceId}/projects/{projectId}/services/{serviceId}", + testWorkspaceId, + testProjectId, + serviceId, + ), + ).andExpect(status().isOk) + .andDo( + document( + "service-delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + parameterWithName("projectId").description("Project ID"), + parameterWithName("serviceId").description("Service ID to delete"), + ), + ), + ) + } + + private class TestAuthenticationPrincipalResolver( + private val userId: UUID, + ) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any = userId + } +} From 23d361ce24851a7f80dc7d2b074b75e97b37697f Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:25:11 +0900 Subject: [PATCH 20/22] chore: delete unused field --- .../proxia/core/api/controller/v1/ServiceControllerDocsTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt index d736999..73fdfae 100644 --- a/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt +++ b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/ServiceControllerDocsTest.kt @@ -61,7 +61,6 @@ class ServiceControllerDocsTest { private val testUserId: UUID = UUID.randomUUID() private val testWorkspaceId = UUID.randomUUID() private val testProjectId = UUID.randomUUID() - private val testGitConnectionId = UUID.randomUUID() @BeforeEach fun setUp(restDocumentation: RestDocumentationContextProvider) { From cdda00e8907491a25a285302860a70dd46e44731 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:29:10 +0900 Subject: [PATCH 21/22] docs: add GitConnectionControllerDocsTest for API documentation --- core/core-api/src/docs/asciidoc/index.adoc | 47 ++++ .../v1/GitConnectionControllerDocsTest.kt | 205 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitConnectionControllerDocsTest.kt diff --git a/core/core-api/src/docs/asciidoc/index.adoc b/core/core-api/src/docs/asciidoc/index.adoc index f5cabef..ad889f1 100644 --- a/core/core-api/src/docs/asciidoc/index.adoc +++ b/core/core-api/src/docs/asciidoc/index.adoc @@ -279,3 +279,50 @@ include::{snippets}/service-delete/path-parameters.adoc[] .Example include::{snippets}/service-delete/http-request.adoc[] include::{snippets}/service-delete/http-response.adoc[] + +[[git-connection]] +== Git Connection + +Git 연동 관리 API입니다. Git 연동은 워크스페이스 하위에 속합니다. + +[[git-connection-list]] +=== Git 연결 목록 조회 + +워크스페이스에 연결된 Git 연동 목록을 조회합니다. + +.Path Parameters +include::{snippets}/git-connection-list/path-parameters.adoc[] + +.Response +include::{snippets}/git-connection-list/response-fields.adoc[] + +.Example +include::{snippets}/git-connection-list/http-request.adoc[] +include::{snippets}/git-connection-list/http-response.adoc[] + +[[git-connection-create]] +=== Git 연결 생성 + +워크스페이스에 새로운 Git 연동을 생성합니다. + +.Path Parameters +include::{snippets}/git-connection-create/path-parameters.adoc[] + +.Request +include::{snippets}/git-connection-create/request-fields.adoc[] + +.Example +include::{snippets}/git-connection-create/http-request.adoc[] +include::{snippets}/git-connection-create/http-response.adoc[] + +[[git-connection-delete]] +=== Git 연결 삭제 + +워크스페이스에 연결된 Git 연동을 삭제합니다. + +.Path Parameters +include::{snippets}/git-connection-delete/path-parameters.adoc[] + +.Example +include::{snippets}/git-connection-delete/http-request.adoc[] +include::{snippets}/git-connection-delete/http-response.adoc[] diff --git a/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitConnectionControllerDocsTest.kt b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitConnectionControllerDocsTest.kt new file mode 100644 index 0000000..c6cb0d8 --- /dev/null +++ b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitConnectionControllerDocsTest.kt @@ -0,0 +1,205 @@ +package kr.proxia.core.api.controller.v1 + +import io.mockk.every +import io.mockk.mockk +import kr.proxia.core.api.controller.v1.request.CreateGitConnectionRequest +import kr.proxia.core.domain.CreateGitConnection +import kr.proxia.core.domain.GitConnectionService +import kr.proxia.core.enums.GitProvider +import kr.proxia.storage.db.core.entity.GitConnection +import kr.proxia.storage.db.core.entity.Workspace +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.core.MethodParameter +import org.springframework.http.MediaType +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse +import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.requestFields +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import org.springframework.restdocs.request.RequestDocumentation.pathParameters +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import tools.jackson.databind.ObjectMapper +import tools.jackson.module.kotlin.jsonMapper +import tools.jackson.module.kotlin.kotlinModule +import java.time.LocalDateTime +import java.util.UUID + +@Tag("restdocs") +@ExtendWith(RestDocumentationExtension::class) +class GitConnectionControllerDocsTest { + private lateinit var mockMvc: MockMvc + private val objectMapper: ObjectMapper = jsonMapper { addModule(kotlinModule()) } + + private val gitConnectionService = mockk() + private val testUserId: UUID = UUID.randomUUID() + private val testWorkspaceId: UUID = UUID.randomUUID() + + @BeforeEach + fun setUp(restDocumentation: RestDocumentationContextProvider) { + mockMvc = + MockMvcBuilders + .standaloneSetup(GitConnectionController(gitConnectionService)) + .setCustomArgumentResolvers(TestAuthenticationPrincipalResolver(testUserId)) + .apply(documentationConfiguration(restDocumentation)) + .build() + } + + @Test + fun `Git 연결 목록 조회`() { + val workspace = Workspace(name = "Test Workspace") + + val connection1 = + GitConnection( + workspace = workspace, + name = "Test Connection 1", + provider = GitProvider.GITHUB, + installationId = "test-installation-id-1", + accessToken = "test-access-token-1", + expiresAt = LocalDateTime.now().plusSeconds(60L), + ) + + val connection2 = + GitConnection( + workspace = workspace, + name = "Test Connection 2", + provider = GitProvider.GITHUB, + installationId = "test-installation-id-2", + accessToken = "test-access-token-2", + expiresAt = LocalDateTime.now().plusSeconds(120L), + ) + + every { gitConnectionService.getConnections(testUserId, testWorkspaceId) } returns + listOf( + connection1, + connection2, + ) + + mockMvc + .perform( + get( + "/api/v1/workspaces/{workspaceId}/git/connections", + testWorkspaceId, + ), + ).andExpect(status().isOk) + .andDo( + document( + "git-connection-list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + ), + responseFields( + fieldWithPath("[].id").description("Git Connection ID"), + fieldWithPath("[].name").description("Git Connection Name"), + fieldWithPath("[].provider").description("Git Connection Provider"), + ), + ), + ) + } + + @Test + fun `Git 연결 생성`() { + val request = + CreateGitConnectionRequest( + installationId = "test-installation-id", + name = "Test Connection", + provider = GitProvider.GITHUB, + ) + + every { + gitConnectionService.createConnection( + testUserId, + testWorkspaceId, + CreateGitConnection( + installationId = "test-installation-id", + name = "Test Connection", + provider = GitProvider.GITHUB, + ), + ) + } returns Unit + + mockMvc + .perform( + post("/api/v1/workspaces/{workspaceId}/git/connections", testWorkspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isOk) + .andDo( + document( + "git-connection-create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + ), + requestFields( + fieldWithPath("installationId").description("Installation ID"), + fieldWithPath("name").description("Git Connection Name"), + fieldWithPath("provider").description("Git Connection Provider"), + ), + ), + ) + } + + @Test + fun `Git 연결 삭제`() { + val connectionId = UUID.randomUUID() + + every { gitConnectionService.deleteConnection(testUserId, testWorkspaceId, connectionId) } returns Unit + + mockMvc + .perform( + delete( + "/api/v1/workspaces/{workspaceId}/git/connections/{connectionId}", + testWorkspaceId, + connectionId, + ), + ).andExpect(status().isOk) + .andDo( + document( + "git-connection-delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + parameterWithName("connectionId").description("Git Connection ID"), + ), + ), + ) + } + + private class TestAuthenticationPrincipalResolver( + private val userId: UUID, + ) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any = userId + } +} From f98ebd0f9f81f6e41d4f99cd012339ab602f0309 Mon Sep 17 00:00:00 2001 From: sehyuk080101 <164876765+sehyuk080101@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:29:27 +0900 Subject: [PATCH 22/22] docs: add GitRepositoryControllerDocsTest for API documentation --- core/core-api/src/docs/asciidoc/index.adoc | 20 +++ .../v1/GitRepositoryControllerDocsTest.kt | 114 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryControllerDocsTest.kt diff --git a/core/core-api/src/docs/asciidoc/index.adoc b/core/core-api/src/docs/asciidoc/index.adoc index ad889f1..fd185d0 100644 --- a/core/core-api/src/docs/asciidoc/index.adoc +++ b/core/core-api/src/docs/asciidoc/index.adoc @@ -326,3 +326,23 @@ include::{snippets}/git-connection-delete/path-parameters.adoc[] .Example include::{snippets}/git-connection-delete/http-request.adoc[] include::{snippets}/git-connection-delete/http-response.adoc[] + +[[git-repository]] +== Git Repository + +Git 저장소 조회 API입니다. Git 저장소는 Git 연결 하위에서 조회합니다. + +[[git-repository-list]] +=== Git 저장소 목록 조회 + +Git 연결에 포함된 Git 저장소 목록을 조회합니다. + +.Path Parameters +include::{snippets}/git-repository-list/path-parameters.adoc[] + +.Response +include::{snippets}/git-repository-list/response-fields.adoc[] + +.Example +include::{snippets}/git-repository-list/http-request.adoc[] +include::{snippets}/git-repository-list/http-response.adoc[] diff --git a/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryControllerDocsTest.kt b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryControllerDocsTest.kt new file mode 100644 index 0000000..e94a9fa --- /dev/null +++ b/core/core-api/src/test/kotlin/kr/proxia/core/api/controller/v1/GitRepositoryControllerDocsTest.kt @@ -0,0 +1,114 @@ +package kr.proxia.core.api.controller.v1 + +import io.mockk.every +import io.mockk.mockk +import kr.proxia.core.domain.GitRepository +import kr.proxia.core.domain.GitRepositoryService +import kr.proxia.core.support.OffsetLimit +import kr.proxia.core.support.Page +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.core.MethodParameter +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse +import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import org.springframework.restdocs.request.RequestDocumentation.pathParameters +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import java.util.UUID + +@Tag("restdocs") +@ExtendWith(RestDocumentationExtension::class) +class GitRepositoryControllerDocsTest { + private lateinit var mockMvc: MockMvc + + private val gitRepositoryService = mockk() + private val testUserId: UUID = UUID.randomUUID() + private val testWorkspaceId: UUID = UUID.randomUUID() + private val testGitConnectionId: UUID = UUID.randomUUID() + + @BeforeEach + fun setUp(restDocumentation: RestDocumentationContextProvider) { + mockMvc = + MockMvcBuilders + .standaloneSetup(GitRepositoryController(gitRepositoryService)) + .setCustomArgumentResolvers(TestAuthenticationPrincipalResolver(testUserId)) + .apply(documentationConfiguration(restDocumentation)) + .build() + } + + @Test + fun `Git repository 목록 조회`() { + val repository1 = GitRepository(name = "repository-1") + val repository2 = GitRepository(name = "repository-2") + + every { + gitRepositoryService.getRepositories( + testUserId, + testWorkspaceId, + testGitConnectionId, + OffsetLimit(0, 2), + ) + } returns + Page( + content = listOf(repository1, repository2), + hasNext = false, + ) + + mockMvc + .perform( + get( + "/api/v1/workspaces/{workspaceId}/git/connections/{connectionId}/repositories", + testWorkspaceId, + testGitConnectionId, + ).queryParam("offset", "0") + .queryParam("limit", "2"), + ).andExpect(status().isOk) + .andDo( + document( + "git-repository-list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("workspaceId").description("Workspace ID"), + parameterWithName("connectionId").description("Connection ID"), + ), + responseFields( + fieldWithPath("content[].name").description("Git Repository Name"), + fieldWithPath("hasNext").description("Whether more repositories exist"), + ), + ), + ) + } + + private class TestAuthenticationPrincipalResolver( + private val userId: UUID, + ) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any = userId + } +}