diff --git a/.gitignore b/.gitignore index 0e7dc419c..e27020d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ - +./api.csv ### STS ### .apt_generated .classpath diff --git a/accountant/accountant-app/src/main/resources/application.yml b/accountant/accountant-app/src/main/resources/application.yml index 48b7e0347..1f08d8860 100644 --- a/accountant/accountant-app/src/main/resources/application.yml +++ b/accountant/accountant-app/src/main/resources/application.yml @@ -113,4 +113,13 @@ app: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} custom-user-language: - enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} \ No newline at end of file + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} + + +# --- Swagger / SpringDoc (env-driven) --- +springdoc: + api-docs: + enabled: ${SWAGGER_API_DOCS_ENABLED:false} + swagger-ui: + enabled: ${SWAGGER_UI_ENABLED:false} + path: ${SWAGGER_UI_PATH:/swagger-ui.html} diff --git a/api/api-app/pom.xml b/api/api-app/pom.xml index 164918767..07c13842b 100644 --- a/api/api-app/pom.xml +++ b/api/api-app/pom.xml @@ -60,11 +60,7 @@ co.nilin.opex.api.ports.postgres api-persister-postgres - - io.springfox - springfox-boot-starter - 3.0.0 - + org.springframework.cloud spring-cloud-starter-vault-config diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/OpenApiConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/OpenApiConfig.kt new file mode 100644 index 000000000..ac4597ed7 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/OpenApiConfig.kt @@ -0,0 +1,38 @@ +package co.nilin.opex.api.app.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig() { + + @Bean + fun opexOpenApi(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("Opex API") + .description("OpenAPI documentation for Opex REST APIs.") + .version("1.0.1-beta.7") + .description("Backend for opex exchange.") + ) + .components( + Components() + .addSecuritySchemes( + "bearerAuth", + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT Bearer token") + ) + ) + } +} + diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/OpenApiCorsConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/OpenApiCorsConfig.kt new file mode 100644 index 000000000..02150efcf --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/OpenApiCorsConfig.kt @@ -0,0 +1,46 @@ +package co.nilin.opex.api.app.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.reactive.CorsWebFilter +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource + +@Configuration(proxyBeanMethods = false) +class OpenApiCorsConfig( + @Value("\${app.swagger.cors.enabled:false}") + private val enabled: Boolean, + + @Value("\${app.swagger.cors.allowed-origins:http://localhost:8110}") + private val allowedOrigins: String +) { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + fun swaggerCorsWebFilter(): CorsWebFilter { + val config = CorsConfiguration().apply { + allowedOrigins = if (enabled) { + this@OpenApiCorsConfig.allowedOrigins + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + } else { + emptyList() + } + + allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + allowedHeaders = listOf("*") + exposedHeaders = listOf("Location", "Content-Disposition") + allowCredentials = false + maxAge = 3600 + } + + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", config) + + return CorsWebFilter(source) + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/SwaggerConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/SwaggerConfig.kt deleted file mode 100644 index 68723c02a..000000000 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/SwaggerConfig.kt +++ /dev/null @@ -1,96 +0,0 @@ -package co.nilin.opex.api.app.config - -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.security.core.annotation.AuthenticationPrincipal -import springfox.documentation.builders.ApiInfoBuilder -import springfox.documentation.builders.OAuthBuilder -import springfox.documentation.builders.PathSelectors -import springfox.documentation.builders.RequestParameterBuilder -import springfox.documentation.service.* -import springfox.documentation.spi.DocumentationType -import springfox.documentation.spi.service.contexts.SecurityContext -import springfox.documentation.spring.web.plugins.Docket -import springfox.documentation.swagger.web.SecurityConfiguration -import springfox.documentation.swagger.web.SecurityConfigurationBuilder -import java.security.Principal -import java.util.* - -@Configuration -class SwaggerConfig { - @Value("\${swagger.authUrl}") - private lateinit var authUrl: String - - @Bean - fun opexApi(): Docket { - return Docket(DocumentationType.SWAGGER_2) - .groupName("opex-api") - .apiInfo(apiInfo()) - .select() - .paths(PathSelectors.regex("^/api/v3.*")) - .build() - .globalRequestParameters( - Collections.singletonList( - RequestParameterBuilder() - .name("content-type") - .description("content-type") - .`in`(ParameterType.HEADER) - .required(true) - .build() - ) - ) - .ignoredParameterTypes(AuthenticationPrincipal::class.java, Principal::class.java) - .useDefaultResponseMessages(false) - .securitySchemes(Collections.singletonList(oauth())) - .securityContexts(Collections.singletonList(securityContext())) - } - - private fun apiInfo(): ApiInfo { - return ApiInfoBuilder() - .title("OPEX API") - .description("Backend for opex exchange.") - .license("MIT License") - .licenseUrl("https://github.com/opexdev/Back-end/blob/feature/1-MVP/LICENSE") - .version("0.1") - .build() - } - - private fun oauth(): SecurityScheme { - return OAuthBuilder() - .name("opex") - .grantTypes(grantTypes()) - .scopes(scopes()) - .build() - } - - private fun scopes(): List { - return listOf(AuthorizationScope("openid", "OpenId")) - } - - private fun grantTypes(): List { - val grantType = ResourceOwnerPasswordCredentialsGrant(authUrl) - return Collections.singletonList(grantType) - } - - private fun securityContext(): SecurityContext { - val securityReference = SecurityReference.builder() - .reference("opex") - .scopes(emptyArray()) - .build() - return SecurityContext.builder() - .securityReferences(Collections.singletonList(securityReference)) - .operationSelector { true } - .build() - } - - @Bean - fun securityInfo(): SecurityConfiguration { - return SecurityConfigurationBuilder.builder() - .clientId("admin-cli") - .realm("opex") - .appName("opex") - .scopeSeparator(",") - .build() - } -} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt index 04e566341..5bf8beadf 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt @@ -4,16 +4,28 @@ import co.nilin.opex.api.app.data.ApiKeyResponse import co.nilin.opex.api.app.data.CreateApiKeyRequest import co.nilin.opex.api.app.data.UpdateApiKeyRequest import co.nilin.opex.common.security.JwtUtils +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.* import java.security.SecureRandom import java.util.* @RestController @RequestMapping("/v1/api-key") +@Tag( + name = "API App - API Keys", + description = "API key management operations." +) class APIKeyController( private val apiKeyService: co.nilin.opex.api.core.spi.APIKeyService ) { - private val rng = SecureRandom() private fun generateSecretBase64(bytes: Int = 48): String { @@ -31,16 +43,40 @@ class APIKeyController( "X-API-BODY-SHA256" to " (optional)" ) - // Create a new API key. Caller must provide a user access token; we bind the key to that user. Returns one-time secret and usage hints. @PostMapping + @Operation( + summary = "Create API key", + description = """POST /v1/api-key. +Security: Bearer user-token required. + +Behavior: Creates a new API key for the authenticated user. The generated secret is returned only once in this response. +Source of values: Use the Bearer token of the user who should own the API key.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ApiKeyResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun create( - @RequestHeader(name = "Authorization", required = false) authorization: String?, + @Parameter(hidden = true) + @RequestHeader(name = "Authorization", required = false) + authorization: String?, @RequestBody req: CreateApiKeyRequest ): ApiKeyResponse { require(!authorization.isNullOrBlank() && authorization.startsWith("Bearer ")) { "Authorization Bearer user token is required" } val userToken = authorization.substringAfter("Bearer ").trim() val (userId, preferredUsername) = parseJwtUser(userToken) - val apiKeyId = req.apiKeyId?.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString() val secret = generateSecretBase64() val stored = apiKeyService.createApiKeyRecord( @@ -65,7 +101,6 @@ class APIKeyController( } private fun parseJwtUser(token: String): Pair { - // Decode JWT payload using common JwtUtils (no signature verification here). val payload = JwtUtils.decodePayload(token) val sub = payload["sub"] as? String val preferred = payload["username"] as? String @@ -73,8 +108,35 @@ class APIKeyController( return Pair(sub!!, preferred) } - // List all API keys (admin-only) — secret is not returned @GetMapping + @Operation( + summary = "List API keys", + description = """GET /v1/api-key. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Returns API key metadata. Secrets are not returned.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = ApiKeyResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun list(): List = apiKeyService.listApiKeyRecords().stream().map { ApiKeyResponse( apiKeyId = it.apiKeyId, @@ -87,9 +149,41 @@ class APIKeyController( ) }.toList() - - // Get one API key (admin-only) — secret is not returned @GetMapping("/{apiKeyId}") + @Operation( + summary = "Get API key", + description = """GET /v1/api-key/{apiKeyId}. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Returns API key metadata. Secret is not returned.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [Parameter( + name = "apiKeyId", + `in` = ParameterIn.PATH, + required = true, + description = "API key id." + )], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ApiKeyResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun get(@PathVariable apiKeyId: String): ApiKeyResponse { val it = apiKeyService.getApiKeyRecord(apiKeyId) ?: throw NoSuchElementException("API key not found: $apiKeyId") return ApiKeyResponse( @@ -103,8 +197,41 @@ class APIKeyController( ) } - // Rotate secret (admin-only). Returns new one-time secret @PostMapping("/{apiKeyId}/rotate") + @Operation( + summary = "Rotate API key secret", + description = """POST /v1/api-key/{apiKeyId}/rotate. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Rotates the API key secret. The new secret is returned only once in this response.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [Parameter( + name = "apiKeyId", + `in` = ParameterIn.PATH, + required = true, + description = "API key id." + )], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ApiKeyResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun rotate(@PathVariable apiKeyId: String): ApiKeyResponse { val newSecret = generateSecretBase64() val stored = apiKeyService.rotateApiKeySecret(apiKeyId, newSecret) @@ -119,8 +246,41 @@ class APIKeyController( ) } - // Update metadata or enable/disable (admin-only) @PutMapping("/{apiKeyId}") + @Operation( + summary = "Update API key", + description = """PUT /v1/api-key/{apiKeyId}. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Updates API key metadata and enabled status. Secret is not returned.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [Parameter( + name = "apiKeyId", + `in` = ParameterIn.PATH, + required = true, + description = "API key id." + )], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ApiKeyResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun update(@PathVariable apiKeyId: String, @RequestBody req: UpdateApiKeyRequest): ApiKeyResponse { val s = apiKeyService.updateApiKeyRecord( apiKeyId = apiKeyId, @@ -140,9 +300,40 @@ class APIKeyController( ) } - // Delete/revoke (admin-only) @DeleteMapping("/{apiKeyId}") + @Operation( + summary = "Delete API key", + description = """DELETE /v1/api-key/{apiKeyId}. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Deletes or revokes the API key. +Response body: No response body.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [Parameter( + name = "apiKeyId", + `in` = ParameterIn.PATH, + required = true, + description = "API key id." + )], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun delete(@PathVariable apiKeyId: String) { apiKeyService.deleteApiKeyRecord(apiKeyId) } -} \ No newline at end of file +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt index e4a8f679c..b029997e1 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt @@ -1,17 +1,42 @@ package co.nilin.opex.api.app.controller import co.nilin.opex.api.core.spi.RateLimitConfigService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v1/rate-limit") +@Tag( + name = "API App - Rate Limit", + description = "Rate-limit administration operations." +) class RateLimitController( private val rateLimitConfig: RateLimitConfigService, ) { - @PostMapping + + @GetMapping + @Operation( + summary = "Reload rate-limit config", + description = """POST /v1/rate-limit. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Reloads rate-limit configuration from the configured source. +Response body: No response body.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response. No response body.", content = [Content()]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun reloadRateLimits() { rateLimitConfig.loadConfig() } -} \ No newline at end of file +} diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 157d4c5ba..353f13cc1 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -149,5 +149,22 @@ app: api: crypto: key: ${api_crypto_key:0e1fd29572ec8c85970d76e3433e96ee} -swagger: - authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token + swagger: + cors: + enabled: true + allowed-origins: "http://localhost:8110" + +# --- Swagger / SpringDoc (env-driven) --- +springdoc: + api-docs: + enabled: ${SWAGGER_API_DOCS_ENABLED:false} + path: ${SWAGGER_API_DOCS_PATH:/v3/api-docs} + swagger-ui: + enabled: ${SWAGGER_UI_ENABLED:false} + path: ${SWAGGER_UI_PATH:/swagger-ui.html} + try-it-out-enabled: ${SWAGGER_TRY_IT_OUT_ENABLED:true} + persist-authorization: ${SWAGGER_PERSIST_AUTHORIZATION:true} + display-request-duration: true + operations-sorter: method + tags-sorter: alpha + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayCommand.kt index 65b52c913..457deb99a 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayCommand.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayCommand.kt @@ -5,9 +5,6 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo import java.math.BigDecimal import java.util.* -enum class GatewayType() { - OnChain, OffChain -} @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayUpdateCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayUpdateCommand.kt new file mode 100644 index 000000000..6378b2c25 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayUpdateCommand.kt @@ -0,0 +1,105 @@ +package co.nilin.opex.api.core.inout + +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import java.math.BigDecimal +import java.util.* + +enum class GatewayType() { + OnChain, OffChain +} + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes( + JsonSubTypes.Type(value = OffChainGatewayCommand::class, name = "OffChain"), + JsonSubTypes.Type(value = OnChainGatewayCommand::class, name = "OnChain"), +) +open abstract class CurrencyGatewayUpdateCommand( + open var currencySymbol: String? = null, + open var gatewayUuid: String? = UUID.randomUUID().toString(), + open var isDepositActive: Boolean?, + open var isWithdrawActive: Boolean?, + open var withdrawFee: BigDecimal? = BigDecimal.ZERO, + open var withdrawAllowed: Boolean? = true, + open var depositAllowed: Boolean? = true, + open var depositMin: BigDecimal? = BigDecimal.ZERO, + open var depositMax: BigDecimal? = BigDecimal.ZERO, + open var withdrawMin: BigDecimal? = BigDecimal.ZERO, + open var withdrawMax: BigDecimal? = BigDecimal.ZERO, + open var displayOrder: Int? = null, +) + +data class OffChainGatewayUpdateCommand( + var transferMethod: TransferMethod, + override var currencySymbol: String? = null, + override var gatewayUuid: String? = UUID.randomUUID().toString(), + override var isDepositActive: Boolean? = true, + override var isWithdrawActive: Boolean? = true, + override var withdrawFee: BigDecimal? = BigDecimal.ZERO, + override var withdrawAllowed: Boolean? = true, + override var depositAllowed: Boolean? = true, + override var depositMin: BigDecimal? = BigDecimal.ZERO, + override var depositMax: BigDecimal? = BigDecimal.ZERO, + override var withdrawMin: BigDecimal? = BigDecimal.ZERO, + override var withdrawMax: BigDecimal? = BigDecimal.ZERO, + override var displayOrder: Int? = null, + + ) : CurrencyGatewayCommand( + currencySymbol, + gatewayUuid, + isDepositActive, + isWithdrawActive, + withdrawFee, + withdrawAllowed, + depositAllowed, + depositMin, + depositMax, + withdrawMin, + withdrawMax, + null, + null, + displayOrder +) + +data class OnChainGatewayUpdateCommand( + + var implementationSymbol: String? = null, + var tokenName: String? = null, + var tokenAddress: String? = null, + var isToken: Boolean? = false, + var decimal: Int, + var chain: String, + override var currencySymbol: String? = null, + override var gatewayUuid: String? = UUID.randomUUID().toString(), + override var isDepositActive: Boolean? = true, + override var isWithdrawActive: Boolean? = true, + override var withdrawFee: BigDecimal? = BigDecimal.ZERO, + override var withdrawAllowed: Boolean? = true, + override var depositAllowed: Boolean? = true, + override var depositMin: BigDecimal? = BigDecimal.ZERO, + override var depositMax: BigDecimal? = BigDecimal.ZERO, + override var withdrawMin: BigDecimal? = BigDecimal.ZERO, + override var withdrawMax: BigDecimal? = BigDecimal.ZERO, + override var displayOrder: Int? = null, +) : CurrencyGatewayCommand( + currencySymbol, + gatewayUuid, + isDepositActive, + isWithdrawActive, + withdrawFee, + withdrawAllowed, + depositAllowed, + depositMin, + depositMax, + withdrawMin, + withdrawMax, + null, + null, + displayOrder +) + + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalCommand.kt index a7c919deb..5439e9feb 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalCommand.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalCommand.kt @@ -1,12 +1,11 @@ package co.nilin.opex.api.core.inout -data class TerminalCommand( +data class TerminalCommand( var uuid: String?, - var owner: String?=null, + var owner: String? = null, var identifier: String, var active: Boolean? = true, var metaData: String, - var description : String?=null, + var description: String? = null, var displayOrder: Int? = null, - -) + ) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalUpdateCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalUpdateCommand.kt new file mode 100644 index 000000000..c77b1a668 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalUpdateCommand.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.core.inout + +data class TerminalUpdateCommand( + var uuid: String?, + var identifier: String, + var active: Boolean? = true, + var metaData: String, + var displayOrder: Int? = null, + ) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserSwapTransactionRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserSwapTransactionRequest.kt new file mode 100644 index 000000000..02504e5e0 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserSwapTransactionRequest.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.core.inout + +data class UserSwapTransactionRequest( + val userId: String? = null, + val sourceSymbol: String?, + val destSymbol: String?, + val startTime: Long? = null, + val endTime: Long? = null, + val limit: Int? = 10, + val offset: Int? = 0, + val ascendingByTime: Boolean = false, + val status: ReservedStatus? = ReservedStatus.Committed +) + +enum class ReservedStatus { + Created, Expired, Committed, +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionRequest.kt index 814642140..898d4c420 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionRequest.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionRequest.kt @@ -11,9 +11,5 @@ data class UserTransactionRequest( val limit: Int? = 10, val offset: Int? = 0, val ascendingByTime: Boolean = false, - val status: ReservedStatus? = ReservedStatus.Committed ) -enum class ReservedStatus { - Created, Expired, Committed, -} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawResponse.kt index ced56abf9..9e9634efa 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawResponse.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawResponse.kt @@ -26,5 +26,5 @@ class WithdrawResponse( val otpRequired: Int? = 0, ) enum class WithdrawType { - CARD_TO_CARD, SHEBA, ON_CHAIN, OFF_CHAIN + ON_CHAIN, OFF_CHAIN } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt index 43203da9d..11382715b 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt @@ -2,9 +2,7 @@ package co.nilin.opex.api.core.inout.analytics import java.math.BigDecimal -/** - * Totals for a single day of user activity (mock values for now). - */ + data class ActivityTotals( val totalBalance: BigDecimal, val totalWithdraw: BigDecimal, diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt index 402f8bd21..a16754629 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt @@ -104,9 +104,6 @@ interface WalletProxy { limit: Int?, ): List - suspend fun deposit( - request: RequestDepositBody - ): TransferResult? suspend fun requestWithdraw( token: String, @@ -127,8 +124,8 @@ interface WalletProxy { suspend fun getQuoteCurrencies(): List - suspend fun getSwapTransactions(token: String, request: UserTransactionRequest): List - suspend fun getSwapTransactionsCount(token: String, request: UserTransactionRequest): Long + suspend fun getSwapTransactions(token: String, request: UserSwapTransactionRequest): List + suspend fun getSwapTransactionsCount(token: String, request: UserSwapTransactionRequest): Long suspend fun requestWithdrawOTP(token: String, withdrawUuid: String, otpType: OTPType): TempOtpResponse suspend fun verifyWithdrawOTP( @@ -150,7 +147,7 @@ interface WalletProxy { suspend fun getSwapTransactionsForAdmin( token: String, - request: UserTransactionRequest + request: UserSwapTransactionRequest ): List suspend fun getTradeHistoryForAdmin( @@ -237,14 +234,14 @@ interface WalletProxy { suspend fun deleteOffChainGatewayLocalization(token: String, id: Long) suspend fun saveTerminal(token: String, terminal: TerminalCommand): TerminalCommand? - suspend fun updateTerminal(token: String, terminalUuid: String, terminal: TerminalCommand): TerminalCommand? + suspend fun updateTerminal(token: String, terminalUuid: String, terminal: TerminalUpdateCommand): TerminalCommand? suspend fun deleteTerminal(token: String, terminalUuid: String) suspend fun getTerminals(token: String): List? suspend fun getTerminal(token: String, terminalUuid: String): TerminalCommand? suspend fun getAssignedGatewayToTerminal(token: String, terminalUuid: String): List? suspend fun assignTerminalsToGateway(token: String, gatewayUuid: String, terminal: List) suspend fun revokeTerminalsToGateway(token: String, gatewayUuid: String, terminal: List) - suspend fun addCurrencyToGateway( + suspend fun addGatewayToCurrency( token: String, currencySymbol: String, gatewayCommand: CurrencyGatewayCommand @@ -254,7 +251,7 @@ interface WalletProxy { token: String, gatewayUuid: String, currencySymbol: String, - gatewayCommand: CurrencyGatewayCommand + gatewayCommand: CurrencyGatewayUpdateCommand ): CurrencyGatewayCommand? suspend fun getGateway(token: String, gatewayUuid: String, currencySymbol: String): CurrencyGatewayCommand? diff --git a/api/api-ports/api-binance-rest/pom.xml b/api/api-ports/api-binance-rest/pom.xml index fd5fdffe3..bff6d12c2 100644 --- a/api/api-ports/api-binance-rest/pom.xml +++ b/api/api-ports/api-binance-rest/pom.xml @@ -98,5 +98,10 @@ swagger-annotations 1.5.20 + + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.8.14 + diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt index f9bd55dc6..3b3996a19 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt @@ -6,6 +6,7 @@ import co.nilin.opex.common.security.ReactiveCustomJwtConverter import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.SecurityWebFiltersOrder @@ -15,6 +16,7 @@ import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.server.WebFilter @@ -25,16 +27,51 @@ class SecurityConfig( @Value("\${app.auth.cert-url}") private val certUrl: String, @Value("\${app.auth.iss-url}") - private val issUrl: String + private val issUrl: String, ) { + @Value("\${swagger.auth.enabled:false}") + private var swaggerAuthEnabled: Boolean = false + + @Value("\${swagger.auth.authority:ROLE_admin}") + private lateinit var swaggerAuthority: String + + @Bean + @Order(0) + fun swaggerSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + val swaggerPaths = arrayOf( + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs", + "/v3/api-docs/**", + "/webjars/**" + ) + + return http + .securityMatcher(ServerWebExchangeMatchers.pathMatchers(*swaggerPaths)) + .csrf { it.disable() } + .authorizeExchange { + if (swaggerAuthEnabled) { + it.anyExchange().hasAuthority(swaggerAuthority) + } else { + it.anyExchange().permitAll() + } + } + .oauth2ResourceServer { + it.jwt { jwt -> + jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) + } + } + .build() + } @Bean - fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + @Order(1) + fun apiSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http.csrf { it.disable() } .authorizeExchange { it.pathMatchers("/actuator/**").permitAll() - .pathMatchers("/swagger-ui/**").permitAll() - .pathMatchers("/swagger-resources/**").permitAll() + .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() .pathMatchers("/v1/rate-limit").hasAuthority("ROLE_admin") .pathMatchers("/v2/api-docs").permitAll() .pathMatchers("/v3/depth").permitAll() @@ -69,7 +106,7 @@ class SecurityConfig( .pathMatchers(HttpMethod.PUT, "/opex/v1/otc/rate").hasAnyAuthority("ROLE_admin", "ROLE_rate_bot") .pathMatchers(HttpMethod.GET, "/opex/v1/otc/**").permitAll() .pathMatchers("/opex/v1/otc/**").hasAuthority("ROLE_admin") - .pathMatchers(HttpMethod.GET, "/opex/v1/bank-account").permitAll() + .pathMatchers(HttpMethod.GET, "/opex/v1/bank-account").authenticated() .pathMatchers("/opex/v1/bank-account/**").hasAuthority("PERM_bank_account:write") .anyExchange().authenticated() } @@ -102,4 +139,5 @@ class SecurityConfig( return decoder } + } diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt index 1d146d47b..12c7af9c1 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt @@ -14,6 +14,7 @@ import io.swagger.annotations.ApiParam import io.swagger.annotations.ApiResponse import io.swagger.annotations.Example import io.swagger.annotations.ExampleProperty +import io.swagger.v3.oas.annotations.Hidden import org.springframework.http.MediaType import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext @@ -24,6 +25,8 @@ import java.time.ZoneId import java.util.* @RestController +@Hidden +@Deprecated("") class AccountController( val queryHandler: MarketUserDataProxy, val matchingGatewayProxy: MatchingGatewayProxy, diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/LandingController.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/LandingController.kt index b6524c586..9666453e1 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/LandingController.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/LandingController.kt @@ -10,6 +10,7 @@ import co.nilin.opex.api.ports.binance.data.GlobalPriceResponse import co.nilin.opex.api.ports.binance.data.MarketInfoResponse import co.nilin.opex.api.ports.binance.data.MarketStatResponse import co.nilin.opex.common.utils.Interval +import io.swagger.v3.oas.annotations.Hidden import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.slf4j.LoggerFactory @@ -19,7 +20,9 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.math.BigDecimal -@RestController // Custom service +@RestController +@Hidden +@Deprecated("")// Custom service @RequestMapping("/v1/landing") class LandingController( private val marketStatProxy: MarketStatProxy, diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/MarketController.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/MarketController.kt index c83f41bac..f93715b5b 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/MarketController.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/MarketController.kt @@ -9,6 +9,8 @@ import co.nilin.opex.api.core.spi.SymbolMapper import co.nilin.opex.api.ports.binance.data.* import co.nilin.opex.common.OpexError import co.nilin.opex.common.utils.Interval +import io.swagger.v3.oas.annotations.Hidden +import io.swagger.v3.oas.annotations.tags.Tag import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.springframework.web.bind.annotation.GetMapping @@ -18,7 +20,6 @@ import org.springframework.web.bind.annotation.RestController import java.math.BigDecimal import java.security.Principal import java.time.ZoneId - @RestController("binanceMarketController") class MarketController( private val accountantProxy: AccountantProxy, @@ -35,6 +36,8 @@ class MarketController( // 500 - 5 // 1000 - 10 // 5000 - 50 + @Hidden + @Deprecated("Deprecated") @GetMapping("/v3/depth") suspend fun orderBook( @RequestParam @@ -72,7 +75,8 @@ class MarketController( val lastOrder = marketDataProxy.lastOrder(localSymbol) return OrderBookResponse(lastOrder?.orderId ?: -1, mappedBidOrders, mappedAskOrders) } - + @Hidden + @Deprecated("Deprecated") @GetMapping("/v3/trades") suspend fun recentTrades( principal: Principal, @@ -99,7 +103,8 @@ class MarketController( ) } } - + @Hidden + @Deprecated("Deprecated") @GetMapping("/v3/ticker/{duration:24h|7d|1M}") suspend fun priceChange( @PathVariable duration: String, @@ -137,7 +142,8 @@ class MarketController( return if (quote.isNullOrEmpty()) result else result.filter { it.quote.equals(quote, true) } } - + @Hidden + @Deprecated("Deprecated") // Weight // 1 for a single symbol // 2 when the symbol parameter is omitted @@ -150,7 +156,8 @@ class MarketController( symbolMapper.toInternalSymbol(symbol) ?: throw OpexError.SymbolNotFound.exception() return marketDataProxy.lastPrice(localSymbol).onEach { symbols[it.symbol]?.let { s -> it.symbol = s } } } - + @Hidden + @Deprecated("Deprecated") @GetMapping("/v3/exchangeInfo") suspend fun pairInfo( @RequestParam(required = false) @@ -198,7 +205,8 @@ class MarketController( // ) // } // } - + @Hidden + @Deprecated("Deprecated") // Custom service @GetMapping("/v3/currencyInfo/quotes") suspend fun getQuoteCurrencies(): List { @@ -206,8 +214,8 @@ class MarketController( .map { it.rightSideWalletSymbol } .distinct() } - // Weight(IP): 1 + @Tag(name = "Internal Charts") @GetMapping("/v3/klines") suspend fun klines( @RequestParam diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/WalletController.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/WalletController.kt index 9b389bdf5..e21f99536 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/WalletController.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/WalletController.kt @@ -6,6 +6,7 @@ import co.nilin.opex.api.ports.binance.data.AssetResponse import co.nilin.opex.api.ports.binance.data.AssetsEstimatedValue import co.nilin.opex.common.security.jwtAuthentication import co.nilin.opex.common.security.tokenValue +import jdk.internal.vm.annotation.Hidden import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.GetMapping @@ -14,6 +15,8 @@ import org.springframework.web.bind.annotation.RestController import java.math.BigDecimal @RestController("walletBinanceController") +@io.swagger.v3.oas.annotations.Hidden +@Deprecated("") class WalletController( private val walletProxy: WalletProxy, private val marketDataProxy: MarketDataProxy, diff --git a/api/api-ports/api-opex-rest/pom.xml b/api/api-ports/api-opex-rest/pom.xml index 0e857f35c..22b37a60d 100644 --- a/api/api-ports/api-opex-rest/pom.xml +++ b/api/api-ports/api-opex-rest/pom.xml @@ -67,9 +67,15 @@ test - io.swagger - swagger-annotations - 1.5.20 + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.8.14 + + + central + https://repo1.maven.org/maven2/ + + diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressBookController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressBookController.kt index 369111929..2455d3ca0 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressBookController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressBookController.kt @@ -5,44 +5,200 @@ import co.nilin.opex.api.core.inout.AddressBookResponse import co.nilin.opex.api.core.spi.ProfileProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext -import org.springframework.web.bind.annotation.* +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.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/opex/v1/address-book") +@Tag( + name = "Address Book", + description = "Manage authenticated user's saved destination addresses." +) class AddressBookController( - val profileProxy: ProfileProxy, + val profileProxy: ProfileProxy ) { @PostMapping + @Operation( + summary = "Create address book entry", + description = """Creates a new address book entry for the authenticated user. +Security: Bearer user-token is required. + +Behavior: `addressType` is a server-provided string. Clients should use the value returned by the related chain/address-type service and must not hardcode a fixed enum list.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "Address book entry creation payload.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = AddAddressBookItemRequest::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Address book entry created successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = AddressBookResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun addAddressBook( @RequestBody request: AddAddressBookItemRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): AddressBookResponse { return profileProxy.addAddressBook(securityContext.jwtAuthentication().tokenValue(), request) } @GetMapping - suspend fun getAddressBook(@CurrentSecurityContext securityContext: SecurityContext): List { + @Operation( + summary = "List address book entries", + description = """Returns all address book entries for the authenticated user. +Security: Bearer user-token is required.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Address book entries returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = AddressBookResponse::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun getAddressBook( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): List { return profileProxy.getAllAddressBooks(securityContext.jwtAuthentication().tokenValue()) } @DeleteMapping("/{id}") + @Operation( + summary = "Delete address book entry", + description = """Deletes one address book entry by ID for the authenticated user. +Security: Bearer user-token is required.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [ + Parameter( + name = "id", + `in` = ParameterIn.PATH, + required = true, + description = "Address book entry ID.", + schema = Schema(type = "integer", format = "int64"), + example = "1" + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "Address book entry deleted successfully. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun deleteAddressBook( @PathVariable("id") id: Long, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ) { profileProxy.deleteAddressBook(securityContext.jwtAuthentication().tokenValue(), id) } @PutMapping("/{id}") + @Operation( + summary = "Update address book entry", + description = """Updates one address book entry by ID for the authenticated user. +Security: Bearer user-token is required. + +Behavior: `addressType` is a server-provided string. Clients should use the value returned by the related chain/address-type service and must not hardcode a fixed enum list.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [ + Parameter( + name = "id", + `in` = ParameterIn.PATH, + required = true, + description = "Address book entry ID.", + schema = Schema(type = "integer", format = "int64"), + example = "1" + ) + ], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "Address book entry update payload.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = AddAddressBookItemRequest::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Address book entry updated successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = AddressBookResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun updateAddressBook( @PathVariable("id") id: Long, @RequestBody request: AddAddressBookItemRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): AddressBookResponse { return profileProxy.updateAddressBook(securityContext.jwtAuthentication().tokenValue(), id, request) } - -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt index e704894c7..0d9b8140d 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt @@ -8,33 +8,84 @@ import co.nilin.opex.api.ports.opex.util.tokenValue import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag @RestController @RequestMapping("/opex/v1/bank-account") +@Tag(name = "Bank Account", description = "Manage authenticated user's bank accounts.") class BankAccountController( - val profileProxy: ProfileProxy, + val profileProxy: ProfileProxy ) { @PostMapping + @Operation( + summary = "Add bank account", + description = """POST /opex/v1/bank-account. +Security: Bearer user-token required. Requires authenticated user JWT. + +Validation: Exactly one of `cardNumber` or `iban` must be provided. Providing both or neither is invalid. +Allowed values: +- status: WAITING, VERIFIED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = BankAccountResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]) + ] + ) suspend fun addBankAccount( @RequestBody request: AddBankAccountRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): BankAccountResponse { return profileProxy.addBankAccount(securityContext.jwtAuthentication().tokenValue(), request) } @GetMapping - suspend fun getBankAccounts(@CurrentSecurityContext securityContext: SecurityContext): List { + @Operation( + summary = "Get bank accounts", + description = """GET /opex/v1/bank-account. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- status: WAITING, VERIFIED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", array = ArraySchema(schema = Schema(implementation = BankAccountResponse::class)))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]) + ] + ) + suspend fun getBankAccounts( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): List { return profileProxy.getBankAccounts(securityContext.jwtAuthentication().tokenValue()) } @DeleteMapping("/{id}") + @Operation( + summary = "Delete bank account", + description = """DELETE /opex/v1/bank-account/{id}. +Security: Bearer user-token required. Required authority: PERM_bank_account:write. +Allowed values: +- status: WAITING, VERIFIED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: PERM_bank_account:write. No response body.", content = [Content()]) + ] + ) suspend fun deleteBankAccount( @PathVariable("id") id: Long, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ) { profileProxy.deleteBankAccount(securityContext.jwtAuthentication().tokenValue(), id) } - - -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ConfigAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ConfigAdminController.kt new file mode 100644 index 000000000..31a2ee981 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ConfigAdminController.kt @@ -0,0 +1,67 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.UpdateUserConfigRequest +import co.nilin.opex.api.core.inout.UpdateWebConfigRequest +import co.nilin.opex.api.core.inout.UserWebConfig +import co.nilin.opex.api.core.spi.ConfigProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import co.nilin.opex.common.data.WebConfig +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +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.PutMapping +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 + +@RestController +@RequestMapping("/opex/v1") +@Tag( + name = "Admin Config", + description = "Default web and user configuration operations." +) +class ConfigAdminController(private val configProxy: ConfigProxy) { + + + @PutMapping("/admin/web/config") + @Operation( + summary = "Update web config", + description = """PUT /opex/v1/admin/web/config. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Allowed values: +- defaultLanguage/supportedLanguages: EN, FA, AR, UZ. +- supportedCalenders: JALALI, HIJRI, GREGORIAN. +- defaultTheme/supportedThemes: DARK, LIGHT, SYSTEM.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = [Content(mediaType = "application/json", schema = Schema(implementation = UpdateWebConfigRequest::class))] + ), + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = WebConfig::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. No response body.", content = [Content()]) + ] + ) + suspend fun updateWebConfig( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: UpdateWebConfigRequest + ): WebConfig { + return configProxy.updateWebConfig(securityContext.jwtAuthentication().tokenValue(), request) + } + +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ConfigController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ConfigController.kt deleted file mode 100644 index c61b07fd2..000000000 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ConfigController.kt +++ /dev/null @@ -1,87 +0,0 @@ -package co.nilin.opex.api.ports.opex.controller - -import co.nilin.opex.api.core.inout.UpdateUserConfigRequest -import co.nilin.opex.api.core.inout.UpdateWebConfigRequest -import co.nilin.opex.api.core.inout.UserLevelConfig -import co.nilin.opex.api.core.inout.UserWebConfig -import co.nilin.opex.api.core.spi.ConfigProxy -import co.nilin.opex.api.ports.opex.util.jwtAuthentication -import co.nilin.opex.api.ports.opex.util.tokenValue -import co.nilin.opex.common.data.WebConfig -import org.springframework.security.core.annotation.CurrentSecurityContext -import org.springframework.security.core.context.SecurityContext -import org.springframework.web.bind.annotation.* - -@RestController -@RequestMapping("/opex/v1") -class ConfigController(private val configProxy: ConfigProxy) { - - @GetMapping("/web/config") - suspend fun getWebConfig(): WebConfig { - return configProxy.getWebConfig() - } - - @PutMapping("/admin/web/config") - suspend fun updateWebConfig( - @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UpdateWebConfigRequest - ): WebConfig { - return configProxy.updateWebConfig(securityContext.jwtAuthentication().tokenValue(), request) - } - - @GetMapping("/user-level/config") - suspend fun getUserLevelConfig(): List { - return configProxy.getUserLevelConfig() - } - - @PutMapping("/admin/user-level/config") - suspend fun updateUserLevelConfig( - @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody userLevelConfig: UserLevelConfig - ): UserLevelConfig { - return configProxy.updateUserLevelConfig(securityContext.jwtAuthentication().tokenValue(), userLevelConfig) - } - - @DeleteMapping("/admin/user-level/config/{userLevel}/{language}") - suspend fun updateUserLevelConfig( - @CurrentSecurityContext securityContext: SecurityContext, - @PathVariable userLevel: String, - @PathVariable language: String - ) { - configProxy.deleteUserLevelConfig(securityContext.jwtAuthentication().tokenValue(), userLevel, language) - } - - @GetMapping("/user/config") - suspend fun getUserConfig(@CurrentSecurityContext securityContext: SecurityContext): UserWebConfig { - return configProxy.getUserConfig(securityContext.jwtAuthentication().tokenValue()) - } - - @PutMapping("/user/config") - suspend fun updateConfig( - @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UpdateUserConfigRequest - ): UserWebConfig { - return configProxy.updateUserConfig(securityContext.jwtAuthentication().tokenValue(), request) - } - - @GetMapping("/user/config/pair") - suspend fun getUserFavoritePair(@CurrentSecurityContext securityContext: SecurityContext): Set { - return configProxy.getUserFavoritePair(securityContext.jwtAuthentication().tokenValue()) - } - - @PostMapping("/user/config/pair/{pair}") - suspend fun addUserFavoritePair( - @CurrentSecurityContext securityContext: SecurityContext, - @PathVariable pair: String - ): Set { - return configProxy.addUserFavoritePair(securityContext.jwtAuthentication().tokenValue(), pair) - } - - @DeleteMapping("/user/config/pair/{pair}") - suspend fun removeUserFavoritePair( - @CurrentSecurityContext securityContext: SecurityContext, - @PathVariable pair: String - ): Set { - return configProxy.removeUserFavoritePair(securityContext.jwtAuthentication().tokenValue(), pair) - } -} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt index 717bfdd61..8a5aaeb3d 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt @@ -1,22 +1,89 @@ package co.nilin.opex.api.ports.opex.controller -import co.nilin.opex.api.core.inout.otc.* +import co.nilin.opex.api.core.inout.otc.CurrencyExchangeRatesResponse +import co.nilin.opex.api.core.inout.otc.CurrencyPair +import co.nilin.opex.api.core.inout.otc.CurrencyPrice +import co.nilin.opex.api.core.inout.otc.ForbiddenPairs +import co.nilin.opex.api.core.inout.otc.Rate +import co.nilin.opex.api.core.inout.otc.Rates +import co.nilin.opex.api.core.inout.otc.SetCurrencyExchangeRateRequest +import co.nilin.opex.api.core.inout.otc.Symbols import co.nilin.opex.api.core.spi.RateProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext -import org.springframework.web.bind.annotation.* +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.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping( "/opex/v1/otc") +@RequestMapping("/opex/v1/otc") class CurrencyRatesController( private val rateProxy: RateProxy ) { // Rates @PostMapping("/rate") + @Operation( + tags = ["OTC Rates"], + summary = "Create OTC exchange rate", + description = """Creates a new OTC exchange rate. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Validation: +- `rate` must be greater than zero. +- `sourceSymbol` and `destSymbol` must be different. + +Request body: SetCurrencyExchangeRateRequest.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "OTC exchange rate creation payload.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = SetCurrencyExchangeRateRequest::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "OTC exchange rate created successfully. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun createRate( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody request: SetCurrencyExchangeRateRequest ) { @@ -25,7 +92,58 @@ class CurrencyRatesController( } @PutMapping("/rate") + @Operation( + tags = ["OTC Rates"], + summary = "Update OTC exchange rate", + description = """Updates an existing OTC exchange rate. + +Required authentication: +- Bearer admin-token or service token is required. +- Required role: ROLE_admin or ROLE_rate_bot. + +Validation: +- `rate` must be greater than zero. +- `sourceSymbol` and `destSymbol` must be different. + +Request body: SetCurrencyExchangeRateRequest. + +Response body: Rates.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "OTC exchange rate update payload.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = SetCurrencyExchangeRateRequest::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "OTC exchange rate updated successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Rates::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin or ROLE_rate_bot. No response body.", + content = [Content()] + ) + ] + ) suspend fun updateRate( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody request: SetCurrencyExchangeRateRequest ): Rates { @@ -34,7 +152,62 @@ class CurrencyRatesController( } @DeleteMapping("/rate/{sourceSymbol}/{destSymbol}") + @Operation( + tags = ["OTC Rates"], + summary = "Delete OTC exchange rate", + description = """Deletes an OTC exchange rate by source and destination symbols. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Path parameters: + +Response body: Rates.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [ + Parameter( + name = "sourceSymbol", + `in` = ParameterIn.PATH, + required = true, + description = "Source currency symbol.", + example = "BTC", + schema = Schema(type = "string") + ), + Parameter( + name = "destSymbol", + `in` = ParameterIn.PATH, + required = true, + description = "Destination currency symbol.", + example = "USDT", + schema = Schema(type = "string") + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "OTC exchange rate deleted successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Rates::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun deleteRate( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable sourceSymbol: String, @PathVariable destSymbol: String @@ -43,11 +216,75 @@ class CurrencyRatesController( } @GetMapping("/rate") + @Operation( + tags = ["OTC Rates"], + summary = "List OTC exchange rates", + description = """Returns configured OTC exchange rates. + +Authentication: +- Public endpoint. No Bearer token is required. + +Response body: Rates.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "OTC exchange rates returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Rates::class) + ) + ] + ) + ] + ) suspend fun fetchRates(): Rates { return rateProxy.fetchRates() } @GetMapping("/rate/{sourceSymbol}/{destSymbol}") + @Operation( + tags = ["OTC Rates"], + summary = "Get OTC exchange rate", + description = """Returns one OTC exchange rate by source and destination symbols. + +Authentication: +- Public endpoint. No Bearer token is required. + +Path parameters: + +Response body: Rate, nullable.""", + parameters = [ + Parameter( + name = "sourceSymbol", + `in` = ParameterIn.PATH, + required = true, + description = "Source currency symbol.", + example = "BTC", + schema = Schema(type = "string") + ), + Parameter( + name = "destSymbol", + `in` = ParameterIn.PATH, + required = true, + description = "Destination currency symbol.", + example = "USDT", + schema = Schema(type = "string") + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "OTC exchange rate returned successfully. Response may be null if no rate exists.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Rate::class) + ) + ] + ) + ] + ) suspend fun fetchRate( @PathVariable sourceSymbol: String, @PathVariable destSymbol: String @@ -57,7 +294,50 @@ class CurrencyRatesController( // Forbidden pairs @PostMapping("/forbidden-pairs") + @Operation( + tags = ["OTC Forbidden Pairs"], + summary = "Add forbidden OTC pair", + description = """Adds a forbidden OTC currency pair. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Validation: +- `sourceSymbol` and `destSymbol` must be different. + +Request body: CurrencyPair.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "Forbidden OTC pair payload.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = CurrencyPair::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Forbidden pair added successfully. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun addForbiddenPair( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody request: CurrencyPair ) { @@ -66,7 +346,62 @@ class CurrencyRatesController( } @DeleteMapping("/forbidden-pairs/{sourceSymbol}/{destSymbol}") + @Operation( + tags = ["OTC Forbidden Pairs"], + summary = "Delete forbidden OTC pair", + description = """Deletes one forbidden OTC currency pair. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Path parameters: + +Response body: ForbiddenPairs.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [ + Parameter( + name = "sourceSymbol", + `in` = ParameterIn.PATH, + required = true, + description = "Source currency symbol.", + example = "BTC", + schema = Schema(type = "string") + ), + Parameter( + name = "destSymbol", + `in` = ParameterIn.PATH, + required = true, + description = "Destination currency symbol.", + example = "IRR", + schema = Schema(type = "string") + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "Forbidden pair deleted successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = ForbiddenPairs::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun deleteForbiddenPair( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable sourceSymbol: String, @PathVariable destSymbol: String @@ -75,13 +410,75 @@ class CurrencyRatesController( } @GetMapping("/forbidden-pairs") + @Operation( + tags = ["OTC Forbidden Pairs"], + summary = "List forbidden OTC pairs", + description = """Returns forbidden OTC currency pairs. + +Authentication: +- Public endpoint. No Bearer token is required. + +Response body: ForbiddenPairs.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Forbidden OTC pairs returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = ForbiddenPairs::class) + ) + ] + ) + ] + ) suspend fun fetchForbiddenPairs(): ForbiddenPairs { return rateProxy.fetchForbiddenPairs() } // Transitive symbols @PostMapping("/transitive-symbols") + @Operation( + tags = ["OTC Transitive Symbols"], + summary = "Add transitive OTC symbols", + description = """Adds transitive symbols used for OTC route calculation. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Request body: Symbols.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "Transitive symbols payload.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Symbols::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Transitive symbols added successfully. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun addTransitiveSymbols( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody symbols: Symbols ) { @@ -89,7 +486,54 @@ class CurrencyRatesController( } @DeleteMapping("/transitive-symbols/{symbol}") + @Operation( + tags = ["OTC Transitive Symbols"], + summary = "Delete one transitive OTC symbol", + description = """Deletes one transitive symbol used for OTC route calculation. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Path parameters: + +Response body: Symbols.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [ + Parameter( + name = "symbol", + `in` = ParameterIn.PATH, + required = true, + description = "Transitive symbol to delete.", + example = "USDT", + schema = Schema(type = "string") + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "Transitive symbol deleted successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Symbols::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun deleteTransitiveSymbols( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable symbol: String ): Symbols { @@ -97,7 +541,54 @@ class CurrencyRatesController( } @DeleteMapping("/transitive-symbols") + @Operation( + tags = ["OTC Transitive Symbols"], + summary = "Delete multiple transitive OTC symbols", + description = """Deletes multiple transitive symbols used for OTC route calculation. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Request body: Symbols. + +Response body: Symbols.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "Transitive symbols delete payload.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Symbols::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Transitive symbols deleted successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Symbols::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun deleteTransitiveSymbols( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody symbols: Symbols ): Symbols { @@ -105,20 +596,194 @@ class CurrencyRatesController( } @GetMapping("/transitive-symbols") + @Operation( + tags = ["OTC Transitive Symbols"], + summary = "List transitive OTC symbols", + description = """Returns transitive symbols used for OTC route calculation. + +Authentication: +- Public endpoint. No Bearer token is required. + +Response body: Symbols.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Transitive symbols returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = Symbols::class) + ) + ] + ) + ] + ) suspend fun fetchTransitiveSymbols(): Symbols { return rateProxy.fetchTransitiveSymbols() } // Routes and prices - @RequestMapping("/route", method = [RequestMethod.POST, RequestMethod.GET]) + @GetMapping("/route") + @Operation( + tags = ["OTC Routes & Prices"], + summary = "Calculate OTC exchange routes", + description = """Returns calculated OTC exchange routes. + +Authentication: +- Public endpoint. No Bearer token is required. + +Query parameters: + +Route calculation behavior: +- If both sourceSymbol and destSymbol are provided, routes are calculated for that specific pair. +- If sourceSymbol is omitted, routes are calculated from all possible source symbols. +- If destSymbol is omitted, routes are calculated to all possible destination symbols. +- If both are omitted, routes are calculated for all possible symbol combinations. +- Do not send the literal string "null". Omit the query parameter when it should be treated as null. + +Response body: CurrencyExchangeRatesResponse.""", + parameters = [ + Parameter( + name = "sourceSymbol", + `in` = ParameterIn.QUERY, + required = false, + description = "Optional source currency symbol. If omitted, all possible source symbols are considered. Do not send the literal string \"null\".", + example = "BTC", + schema = Schema(type = "string") + ), + Parameter( + name = "destSymbol", + `in` = ParameterIn.QUERY, + required = false, + description = "Optional destination currency symbol. If omitted, all possible destination symbols are considered. Do not send the literal string \"null\".", + example = "USDT", + schema = Schema(type = "string") + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "OTC exchange routes returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = CurrencyExchangeRatesResponse::class) + ) + ] + ) + ] + ) suspend fun fetchRoutes( - @RequestParam("sourceSymbol") sourceSymbol: String? = null, - @RequestParam("destSymbol") destSymbol: String? = null + @RequestParam("sourceSymbol", required = false) sourceSymbol: String? = null, + @RequestParam("destSymbol", required = false) destSymbol: String? = null + ): CurrencyExchangeRatesResponse { + return rateProxy.fetchRoutes(sourceSymbol, destSymbol) + } + + @PostMapping("/route") + @Operation( + tags = ["OTC Routes & Prices"], + summary = "Calculate OTC exchange routes as admin", + description = """Returns calculated OTC exchange routes. + +Required authentication: +- Bearer admin-token is required. +- Required role: ROLE_admin. + +Query parameters: + +Route calculation behavior: +- If both sourceSymbol and destSymbol are provided, routes are calculated for that specific pair. +- If sourceSymbol is omitted, routes are calculated from all possible source symbols. +- If destSymbol is omitted, routes are calculated to all possible destination symbols. +- If both are omitted, routes are calculated for all possible symbol combinations. +- Do not send the literal string "null". Omit the query parameter when it should be treated as null. + +Response body: CurrencyExchangeRatesResponse.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [ + Parameter( + name = "sourceSymbol", + `in` = ParameterIn.QUERY, + required = false, + description = "Optional source currency symbol. If omitted, all possible source symbols are considered. Do not send the literal string \"null\".", + example = "BTC", + schema = Schema(type = "string") + ), + Parameter( + name = "destSymbol", + `in` = ParameterIn.QUERY, + required = false, + description = "Optional destination currency symbol. If omitted, all possible destination symbols are considered. Do not send the literal string \"null\".", + example = "USDT", + schema = Schema(type = "string") + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "OTC exchange routes returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = CurrencyExchangeRatesResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required role is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) + suspend fun fetchRoutesAsAdmin( + @RequestParam("sourceSymbol", required = false) sourceSymbol: String? = null, + @RequestParam("destSymbol", required = false) destSymbol: String? = null ): CurrencyExchangeRatesResponse { return rateProxy.fetchRoutes(sourceSymbol, destSymbol) } @GetMapping("/currency/price") + @Operation( + tags = ["OTC Routes & Prices"], + summary = "Get OTC currency prices", + description = """Returns OTC currency prices for the requested unit. + +Authentication: +- Public endpoint. No Bearer token is required. + +Query parameters: + +Response body: Array.""", + parameters = [ + Parameter( + name = "unit", + `in` = ParameterIn.QUERY, + required = true, + description = "Pricing unit.", + example = "USDT", + schema = Schema(type = "string") + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "Currency prices returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = CurrencyPrice::class)) + ) + ] + ) + ] + ) suspend fun getPrice( @RequestParam("unit") unit: String ): List { diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositAdminController.kt index 9ae00782d..92628de81 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositAdminController.kt @@ -9,18 +9,41 @@ import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* import java.math.BigDecimal +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag @RestController @RequestMapping("/opex/v1/admin/deposit") +@Tag(name = "Deposit Admin", description = "Admin manual deposit operations.") class DepositAdminController( - private val walletProxy: WalletProxy, + private val walletProxy: WalletProxy ) { @PostMapping("/manually/{amount}_{symbol}/{receiverUuid}") + @Operation( + summary = "Deposit manually", + description = """POST /opex/v1/admin/deposit/manually/{amount}_{symbol}/{receiverUuid}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- receiverWalletType/sourceWalletType where used: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = TransferResult::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun depositManually( @PathVariable("symbol") symbol: String, @PathVariable("receiverUuid") receiverUuid: String, @PathVariable("amount") amount: BigDecimal, @RequestBody request: ManualTransferRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): TransferResult { return walletProxy.depositManually( @@ -32,4 +55,4 @@ class DepositAdminController( ) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositController.kt deleted file mode 100644 index fd055219b..000000000 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositController.kt +++ /dev/null @@ -1,19 +0,0 @@ -package co.nilin.opex.api.ports.opex.controller - -import co.nilin.opex.api.core.inout.RequestDepositBody -import co.nilin.opex.api.core.inout.TransferResult -import co.nilin.opex.api.core.spi.WalletProxy -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 - -@RestController -@RequestMapping("/opex/v1/deposit") -class DepositController(private val walletProxy: WalletProxy) { - - @PostMapping - suspend fun deposit(@RequestBody request: RequestDepositBody): TransferResult? { - return walletProxy.deposit(request) - } -} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/GatewayAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/GatewayAdminController.kt index 85889788f..ddf72c5b9 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/GatewayAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/GatewayAdminController.kt @@ -1,34 +1,391 @@ package co.nilin.opex.api.ports.opex.controller import co.nilin.opex.api.core.inout.CurrencyGatewayCommand +import co.nilin.opex.api.core.inout.CurrencyGatewayUpdateCommand +import co.nilin.opex.api.core.inout.OffChainGatewayCommand +import co.nilin.opex.api.core.inout.OnChainGatewayCommand import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* +import io.swagger.v3.oas.annotations.parameters.RequestBody as SwaggerRequestBody @RestController @RequestMapping("/opex/v1/admin") +@Tag(name = "Gateway Admin", description = "Admin gateway management for currencies.") class GatewayAdminController( private val walletProxy: WalletProxy, ) { + @PostMapping("/{currencySymbol}/gateway") + @Operation( + summary = "Add currency gateway", + description = """ +Security: +- Bearer admin-token is required. +- Required authority: ROLE_admin. + +Behavior: +- OffChain gateways use type=OffChain and transferMethod. +- OnChain native gateways use type=OnChain and isToken=false. +- Token gateways use type=OnChain and isToken=true with token fields. + +Source of values: +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD. +- chain values should come from the server-provided chain list. + +Response body: CurrencyGatewayCommand. + """, + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = SwaggerRequestBody( + required = true, + description = "CurrencyGatewayCommand. Use one of: OffChainGatewayCommand, OnChainGatewayCommand, or OnChainGatewayCommand with isToken=true for token gateways.", + content = [ + Content( + mediaType = "application/json", + schema = Schema( + oneOf = [OffChainGatewayCommand::class, OnChainGatewayCommand::class], + discriminatorProperty = "type" + ), + examples = [ + ExampleObject( + name = "OffChain gateway", + summary = "OffChainGatewayCommand", + value = """ +{ + "type": "OffChain", + "currencySymbol": "IRT", + "gatewayUuid": "ofg-card-irt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 100000, + "depositMax": 500000000, + "withdrawMin": 100000, + "withdrawMax": 500000000, + "depositDescription": "Card deposit", + "withdrawDescription": "Card withdraw", + "displayOrder": 1, + "transferMethod": "CARD" +} + """ + ), + ExampleObject( + name = "OnChain native gateway", + summary = "OnChainGatewayCommand with isToken=false", + value = """ +{ + "type": "OnChain", + "currencySymbol": "BTC", + "gatewayUuid": "ong-bitcoin-btc", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0.0005, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 0.0001, + "depositMax": 10, + "withdrawMin": 0.0001, + "withdrawMax": 5, + "depositDescription": "Bitcoin deposit", + "withdrawDescription": "Bitcoin withdraw", + "displayOrder": 1, + "implementationSymbol": "BTC", + "tokenName": null, + "tokenAddress": null, + "isToken": false, + "decimal": 8, + "chain": "bitcoin" +} + """ + ), + ExampleObject( + name = "OnChain token gateway", + summary = "OnChainGatewayCommand with isToken=true", + value = """ +{ + "type": "OnChain", + "currencySymbol": "USDT", + "gatewayUuid": "ong-tron-usdt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 1, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 1, + "depositMax": 100000, + "withdrawMin": 1, + "withdrawMax": 50000, + "depositDescription": "USDT TRC20 deposit", + "withdrawDescription": "USDT TRC20 withdraw", + "displayOrder": 2, + "implementationSymbol": "USDT", + "tokenName": "Tether USD", + "tokenAddress": "TX0000000000000000000000000000000000", + "isToken": true, + "decimal": 6, + "chain": "tron" +} + """ + ) + ] + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Gateway created successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema( + oneOf = [OffChainGatewayCommand::class, OnChainGatewayCommand::class], + discriminatorProperty = "type" + ), + examples = [ + ExampleObject( + name = "OffChain gateway response", value = """ +{ + "type": "OffChain", + "currencySymbol": "IRT", + "gatewayUuid": "ofg-card-irt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 100000, + "depositMax": 500000000, + "withdrawMin": 100000, + "withdrawMax": 500000000, + "depositDescription": "Card deposit", + "withdrawDescription": "Card withdraw", + "displayOrder": 1, + "transferMethod": "CARD" +} + """ + ), + ExampleObject( + name = "OnChain native gateway response", value = """ +{ + "type": "OnChain", + "currencySymbol": "BTC", + "gatewayUuid": "ong-bitcoin-btc", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0.0005, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 0.0001, + "depositMax": 10, + "withdrawMin": 0.0001, + "withdrawMax": 5, + "depositDescription": "Bitcoin deposit", + "withdrawDescription": "Bitcoin withdraw", + "displayOrder": 1, + "implementationSymbol": "BTC", + "tokenName": null, + "tokenAddress": null, + "isToken": false, + "decimal": 8, + "chain": "bitcoin" +} + """ + ), + ExampleObject( + name = "OnChain token gateway response", value = """ +{ + "type": "OnChain", + "currencySymbol": "USDT", + "gatewayUuid": "ong-tron-usdt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 1, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 1, + "depositMax": 100000, + "withdrawMin": 1, + "withdrawMax": 50000, + "depositDescription": "USDT TRC20 deposit", + "withdrawDescription": "USDT TRC20 withdraw", + "displayOrder": 2, + "implementationSymbol": "USDT", + "tokenName": "Tether USD", + "tokenAddress": "TX0000000000000000000000000000000000", + "isToken": true, + "decimal": 6, + "chain": "tron" +} + """ + ) + ] + ) + ] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. No response body.", content = [Content()]) + ] + ) suspend fun addGatewayToCurrency( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("currencySymbol") currencySymbol: String, @RequestBody body: CurrencyGatewayCommand, ): CurrencyGatewayCommand? { - return walletProxy.addCurrencyToGateway(securityContext.jwtAuthentication().tokenValue(), currencySymbol, body) + return walletProxy.addGatewayToCurrency(securityContext.jwtAuthentication().tokenValue(), currencySymbol, body) } - @PutMapping("/{currencySymbol}/gateway/{uuid}") + @Operation( + summary = "Update currency gateway", + description = """ +Security: +- Bearer admin-token is required. +- Required authority: ROLE_admin. + +Behavior: +- OffChain gateways use type=OffChain and transferMethod. +- OnChain native gateways use type=OnChain and isToken=false. +- Token gateways use type=OnChain and isToken=true with token fields. + +Source of values: +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD. +- chain values should come from the server-provided chain list. + +Response body: CurrencyGatewayCommand. + """, + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = SwaggerRequestBody( + required = true, + description = "CurrencyGatewayCommand. Use one of: OffChainGatewayCommand, OnChainGatewayCommand, or OnChainGatewayCommand with isToken=true for token gateways.", + content = [ + Content( + mediaType = "application/json", + schema = Schema( + oneOf = [OffChainGatewayCommand::class, OnChainGatewayCommand::class], + discriminatorProperty = "type" + ), + examples = [ + ExampleObject( + name = "OffChain gateway", summary = "OffChainGatewayCommand", value = """ +{ + "type": "OffChain", + "currencySymbol": "IRT", + "gatewayUuid": "ofg-card-irt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 100000, + "depositMax": 500000000, + "withdrawMin": 100000, + "withdrawMax": 500000000, + "depositDescription": "Card deposit", + "withdrawDescription": "Card withdraw", + "displayOrder": 1, + "transferMethod": "CARD" +} + """ + ), + ExampleObject( + name = "OnChain native gateway", + summary = "OnChainGatewayCommand with isToken=false", + value = """ +{ + "type": "OnChain", + "currencySymbol": "BTC", + "gatewayUuid": "ong-bitcoin-btc", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0.0005, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 0.0001, + "depositMax": 10, + "withdrawMin": 0.0001, + "withdrawMax": 5, + "depositDescription": "Bitcoin deposit", + "withdrawDescription": "Bitcoin withdraw", + "displayOrder": 1, + "implementationSymbol": "BTC", + "tokenName": null, + "tokenAddress": null, + "isToken": false, + "decimal": 8, + "chain": "bitcoin" +} + """ + ), + ExampleObject( + name = "OnChain token gateway", + summary = "OnChainGatewayCommand with isToken=true", + value = """ +{ + "type": "OnChain", + "currencySymbol": "USDT", + "gatewayUuid": "ong-tron-usdt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 1, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 1, + "depositMax": 100000, + "withdrawMin": 1, + "withdrawMax": 50000, + "depositDescription": "USDT TRC20 deposit", + "withdrawDescription": "USDT TRC20 withdraw", + "displayOrder": 2, + "implementationSymbol": "USDT", + "tokenName": "Tether USD", + "tokenAddress": "TX0000000000000000000000000000000000", + "isToken": true, + "decimal": 6, + "chain": "tron" +} + """ + ) + ] + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Gateway updated successfully.", + content = [Content( + mediaType = "application/json", + schema = Schema( + oneOf = [OffChainGatewayCommand::class, OnChainGatewayCommand::class], + discriminatorProperty = "type" + ) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. No response body.", content = [Content()]) + ] + ) suspend fun updateGateway( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("uuid") gatewayUuid: String, @PathVariable("currencySymbol") currencySymbol: String, - @RequestBody body: CurrencyGatewayCommand + @RequestBody body: CurrencyGatewayUpdateCommand, ): CurrencyGatewayCommand? { return walletProxy.updateGateway( securityContext.jwtAuthentication().tokenValue(), @@ -39,7 +396,117 @@ class GatewayAdminController( } @GetMapping("/{currencySymbol}/gateway/{uuid}") + @Operation( + summary = "Get currency gateway", + description = """ +Security: +- Bearer admin-token is required. +- Required authority: ROLE_admin. + +Behavior: +- Response can be OffChainGatewayCommand, OnChainGatewayCommand, or OnChainGatewayCommand with isToken=true for token gateways. + +Response body: CurrencyGatewayCommand. + """, + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Gateway returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema( + oneOf = [OffChainGatewayCommand::class, OnChainGatewayCommand::class], + discriminatorProperty = "type" + ), + examples = [ + ExampleObject( + name = "OffChain gateway response", value = """ +{ + "type": "OffChain", + "currencySymbol": "IRT", + "gatewayUuid": "ofg-card-irt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 100000, + "depositMax": 500000000, + "withdrawMin": 100000, + "withdrawMax": 500000000, + "depositDescription": "Card deposit", + "withdrawDescription": "Card withdraw", + "displayOrder": 1, + "transferMethod": "CARD" +} + """ + ), + ExampleObject( + name = "OnChain native gateway response", value = """ +{ + "type": "OnChain", + "currencySymbol": "BTC", + "gatewayUuid": "ong-bitcoin-btc", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 0.0005, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 0.0001, + "depositMax": 10, + "withdrawMin": 0.0001, + "withdrawMax": 5, + "depositDescription": "Bitcoin deposit", + "withdrawDescription": "Bitcoin withdraw", + "displayOrder": 1, + "implementationSymbol": "BTC", + "tokenName": null, + "tokenAddress": null, + "isToken": false, + "decimal": 8, + "chain": "bitcoin" +} + """ + ), + ExampleObject( + name = "OnChain token gateway response", value = """ +{ + "type": "OnChain", + "currencySymbol": "USDT", + "gatewayUuid": "ong-tron-usdt", + "isDepositActive": true, + "isWithdrawActive": true, + "withdrawFee": 1, + "withdrawAllowed": true, + "depositAllowed": true, + "depositMin": 1, + "depositMax": 100000, + "withdrawMin": 1, + "withdrawMax": 50000, + "depositDescription": "USDT TRC20 deposit", + "withdrawDescription": "USDT TRC20 withdraw", + "displayOrder": 2, + "implementationSymbol": "USDT", + "tokenName": "Tether USD", + "tokenAddress": "TX0000000000000000000000000000000000", + "isToken": true, + "decimal": 6, + "chain": "tron" +} + """ + ) + ] + ) + ] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. No response body.", content = [Content()]) + ] + ) suspend fun getGateway( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("uuid") gatewayUuid: String, @PathVariable("currencySymbol") currencySymbol: String, @@ -52,12 +519,32 @@ class GatewayAdminController( } @DeleteMapping("/{currencySymbol}/gateway/{uuid}") + @Operation( + summary = "Delete currency gateway", + description = """ +Security: +- Bearer admin-token is required. +- Required authority: ROLE_admin. + +Response body: No response body. + """, + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Gateway deleted successfully. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. No response body.", content = [Content()]) + ] + ) suspend fun deleteGateway( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("uuid") gatewayUuid: String, @PathVariable("currencySymbol") currencySymbol: String, ) { walletProxy.deleteGateway(securityContext.jwtAuthentication().tokenValue(), gatewayUuid, currencySymbol) } - } diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/LocalizationAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/LocalizationAdminController.kt index dcb038f74..e84ced5fc 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/LocalizationAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/LocalizationAdminController.kt @@ -9,15 +9,38 @@ import co.nilin.opex.common.OpexError import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag @RestController @RequestMapping("/opex/v1/admin") +@Tag(name = "Localization Admin", description = "Admin localization management for currencies, terminals, and gateways.") class LocalizationAdminController( private val walletProxy: WalletProxy, private val blockchainGatewayProxy: BlockchainGatewayProxy ) { @GetMapping("/currency/{currency}/localization") + @Operation( + summary = "Get currency localizations", + description = """GET /opex/v1/admin/currency/{currency}/localization. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = CurrencyLocalizationResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun getCurrencyLocalizations( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("currency") currency: String ): CurrencyLocalizationResponse { @@ -25,7 +48,21 @@ class LocalizationAdminController( } @PostMapping("/currency/{currency}/localization") + @Operation( + summary = "Save or update currency localizations", + description = """POST /opex/v1/admin/currency/{currency}/localization. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = CurrencyLocalizationResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun saveCurrencyLocalizations( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("currency") currency: String, @RequestBody currencyLocalizations: List @@ -38,7 +75,21 @@ class LocalizationAdminController( } @DeleteMapping("/currency/localization/{id}") + @Operation( + summary = "Delete currency localization", + description = """DELETE /opex/v1/admin/currency/localization/{id}. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun deleteCurrencyLocalization( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("id") id: Long ) { @@ -46,7 +97,21 @@ class LocalizationAdminController( } @GetMapping("/terminal/{terminalUuid}/localization") + @Operation( + summary = "Get terminal localizations", + description = """GET /opex/v1/admin/terminal/{terminalUuid}/localization. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = TerminalLocalizationResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun getTerminalLocalizations( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("terminalUuid") terminalUuid: String ): TerminalLocalizationResponse { @@ -54,7 +119,21 @@ class LocalizationAdminController( } @PostMapping("/terminal/{terminalUuid}/localization") + @Operation( + summary = "Save or update terminal localizations", + description = """POST /opex/v1/admin/terminal/{terminalUuid}/localization. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = TerminalLocalizationResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun saveTerminalLocalizations( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("terminalUuid") terminalUuid: String, @RequestBody terminalLocalizations: List @@ -67,7 +146,21 @@ class LocalizationAdminController( } @DeleteMapping("/terminal/localization/{id}") + @Operation( + summary = "Delete terminal localization", + description = """DELETE /opex/v1/admin/terminal/localization/{id}. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun deleteTerminalLocalization( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("id") id: Long ) { @@ -75,7 +168,21 @@ class LocalizationAdminController( } @GetMapping("/gateway/{gatewayUuid}/localization") + @Operation( + summary = "Get gateway localization", + description = """GET /opex/v1/admin/gateway/{gatewayUuid}/localization. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = GatewayLocalizationResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun getGatewayLocalization( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("gatewayUuid") gatewayUuid: String ): GatewayLocalizationResponse { @@ -92,7 +199,21 @@ class LocalizationAdminController( } @PostMapping("/gateway/{gatewayUuid}/localization") + @Operation( + summary = "Save or update gateway localizations", + description = """POST /opex/v1/admin/gateway/{gatewayUuid}/localization. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = GatewayLocalizationResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun saveGatewayLocalizations( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("gatewayUuid") gatewayUuid: String, @RequestBody gatewayLocalizations: List @@ -113,7 +234,21 @@ class LocalizationAdminController( } @DeleteMapping("/gateway/{gatewayUuid}/localization/{id}") + @Operation( + summary = "Delete gateway localization", + description = """DELETE /opex/v1/admin/gateway/{gatewayUuid}/localization/{id}. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Behavior: Send one localization object per language. Existing items can include `id`; new items should use null or omit `id`.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun deleteGatewayLocalization( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @PathVariable("id") id: Long, @PathVariable("gatewayUuid") gatewayUuid: String @@ -130,4 +265,4 @@ class LocalizationAdminController( ) } else throw OpexError.GatewayNotFount.exception() } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt index 5ea8c718d..290c55827 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt @@ -8,16 +8,27 @@ import co.nilin.opex.api.ports.opex.data.OrderBookResponse import co.nilin.opex.api.ports.opex.data.RecentTradeResponse import co.nilin.opex.common.OpexError import co.nilin.opex.common.utils.Interval +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.springframework.beans.factory.annotation.Value -import org.springframework.web.bind.annotation.* +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.math.BigDecimal import java.time.ZoneId -import kotlin.collections.mapNotNull @RestController("opexMarketController") @RequestMapping("/opex/v1/market") +@Tag(name = "Market", description = "Public market information, order book, trades, tickers, and basic market data.") class MarketController( private val accountantProxy: AccountantProxy, private val marketStatProxy: MarketStatProxy, @@ -26,17 +37,61 @@ class MarketController( private val matchingGatewayProxy: MatchingGatewayProxy, private val blockChainGatewayProxy: BlockchainGatewayProxy, @Value("\${app.user-activity-reference-currency}") - private val userActivityReferenceCurrency: String, + private val userActivityReferenceCurrency: String ) { private val orderBookValidLimits = arrayListOf(5, 10, 20, 50, 100, 500, 1000, 5000) private val validDurations = arrayListOf("24h", "7d", "1M") @GetMapping("/currency") + @Operation( + summary = "Get currencies", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Response body: +- Array of currency data. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Currencies returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = CurrencyData::class)) + ) + ] + ) + ] + ) suspend fun getCurrencies(): List { return walletProxy.getCurrencies() } @GetMapping("/pair") + @Operation( + summary = "Get trading pairs", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Response body: +- Array of pair info. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Trading pairs returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = PairInfoResponse::class)) + ) + ] + ) + ] + ) suspend fun getPairs(): List { val pairSettings = matchingGatewayProxy.getPairSettings().associateBy { it.pair } @@ -56,27 +111,167 @@ class MarketController( } @GetMapping("/chain") + @Operation( + summary = "Get chains", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Source of values: +- Chain names returned here should be used by clients when selecting chain-based fields in other APIs. + +Response body: +- Array of chain info. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Chains returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = ChainInfo::class)) + ) + ] + ) + ] + ) suspend fun getChains(): List { - return blockChainGatewayProxy.getChainInfo() + return blockChainGatewayProxy.getChainInfo() } @GetMapping("/currency/gateway") + @Operation( + summary = "Get currency gateways", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Behavior: +- `includeOffChainGateways` defaults to `true`. +- `includeOnChainGateways` defaults to `true`. +- Set either flag to `false` to exclude that gateway type. + +Response body: +- Array of currency gateway commands. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Currency gateways returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = CurrencyGatewayCommand::class)) + ) + ] + ) + ] + ) suspend fun getCurrencyGateways( - @RequestParam(defaultValue = "true") includeOffChainGateways: Boolean, - @RequestParam(defaultValue = "true") includeOnChainGateways: Boolean, + @Parameter( + name = "includeOffChainGateways", + description = "Whether OffChain gateways should be included. Defaults to true.", + required = false, + schema = Schema(type = "boolean", defaultValue = "true") + ) + @RequestParam(name = "includeOffChainGateways", defaultValue = "true") + includeOffChainGateways: Boolean, + + @Parameter( + name = "includeOnChainGateways", + description = "Whether OnChain gateways should be included. Defaults to true.", + required = false, + schema = Schema(type = "boolean", defaultValue = "true") + ) + @RequestParam(name = "includeOnChainGateways", defaultValue = "true") + includeOnChainGateways: Boolean ): List { return walletProxy.getGateWays(includeOffChainGateways, includeOnChainGateways) } @GetMapping("/fee") + @Operation( + summary = "Get fee configs", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Response body: +- Array of fee config data. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Fee configs returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = FeeConfig::class)) + ) + ] + ) + ] + ) suspend fun getFeeConfigs(): List { return accountantProxy.getFeeConfigs() } @GetMapping("/stats") + @Operation( + summary = "Get market stats", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Behavior: +- `interval` uses interval labels such as `1d`, `1w`, `1M`, or `3M`. +- If `interval` is invalid, the service falls back to `1w`. +- `limit` defaults to `100`. +- `limit` is clamped to the range `1..1000`. + +Response body: +- Market statistics response. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Market stats returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = MarketStatResponse::class) + ) + ] + ) + ] + ) suspend fun getMarketStats( - @RequestParam interval: String, - @RequestParam(required = false) limit: Int? + @Parameter( + name = "interval", + description = "Interval label. Invalid values fall back to 1w.", + required = true, + schema = Schema( + type = "string", + allowableValues = [ + "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", + "24h", "1d", "3d", "7d", "1w", "1M", "3M", "1Y" + ] + ), + example = "1w" + ) + @RequestParam(name = "interval") + interval: String, + + @Parameter( + name = "limit", + description = "Optional result limit. Defaults to 100 and is clamped to 1..1000.", + required = false, + schema = Schema(type = "integer", format = "int32", defaultValue = "100"), + example = "100" + ) + @RequestParam(name = "limit", required = false) + limit: Int? ): MarketStatResponse = coroutineScope { val intervalEnum = Interval.findByLabel(interval) ?: Interval.Week val validLimit = getValidLimit(limit) @@ -106,21 +301,110 @@ class MarketController( } @GetMapping("/info") - suspend fun getMarketInfo(@RequestParam interval: String): MarketInfoResponse { + @Operation( + summary = "Get market info", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Behavior: +- `interval` uses interval labels such as `1d`, `1w`, `1M`, or `3M`. +- If `interval` is invalid, the service falls back to `3M`. + +Response body: +- Market info response. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Market info returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = MarketInfoResponse::class) + ) + ] + ) + ] + ) + suspend fun getMarketInfo( + @Parameter( + name = "interval", + description = "Interval label. Invalid values fall back to 3M.", + required = true, + schema = Schema( + type = "string", + allowableValues = [ + "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", + "24h", "1d", "3d", "7d", "1w", "1M", "3M", "1Y" + ] + ), + example = "3M" + ) + @RequestParam(name = "interval") + interval: String + ): MarketInfoResponse { val intervalEnum = Interval.findByLabel(interval) ?: Interval.ThreeMonth return MarketInfoResponse( marketDataProxy.countActiveUsers(intervalEnum), marketDataProxy.countTotalOrders(intervalEnum), - marketDataProxy.countTotalTrades(intervalEnum), + marketDataProxy.countTotalTrades(intervalEnum) ) } @GetMapping("/depth") + @Operation( + summary = "Get order book", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Validation: +- `limit` must be one of: `5`, `10`, `20`, `50`, `100`, `500`, `1000`, `5000`. + +Behavior: +- `limit` defaults to `100` when omitted. + +Response body: +- Order book response. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Order book returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = OrderBookResponse::class) + ) + ] + ) + ] + ) suspend fun orderBook( - @RequestParam + @Parameter( + name = "symbol", + description = "Market symbol.", + required = true, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol") symbol: String, - @RequestParam(required = false) - limit: Int? // Default 100; max 5000. Valid limits:[5, 10, 20, 50, 100, 500, 1000, 5000] + + @Parameter( + name = "limit", + description = "Optional order book limit. Defaults to 100.", + required = false, + schema = Schema( + type = "integer", + format = "int32", + defaultValue = "100", + allowableValues = ["5", "10", "20", "50", "100", "500", "1000", "5000"] + ), + example = "100" + ) + @RequestParam(name = "limit", required = false) + limit: Int? ): OrderBookResponse { val validLimit = limit ?: 100 if (!orderBookValidLimits.contains(validLimit)) @@ -153,11 +437,53 @@ class MarketController( } @GetMapping("/trades") + @Operation( + summary = "Get recent trades", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Validation: +- `limit` must be between `1` and `1000`. + +Behavior: +- `limit` defaults to `500` when omitted. + +Response body: +- Array of recent trades. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Recent trades returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = RecentTradeResponse::class)) + ) + ] + ) + ] + ) suspend fun recentTrades( - @RequestParam + @Parameter( + name = "symbol", + description = "Market symbol.", + required = true, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol") symbol: String, - @RequestParam(required = false) - limit: Int? // Default 500; max 1000. + + @Parameter( + name = "limit", + description = "Optional recent-trade limit. Defaults to 500. Valid range: 1..1000.", + required = false, + schema = Schema(type = "integer", format = "int32", defaultValue = "500", minimum = "1", maximum = "1000"), + example = "500" + ) + @RequestParam(name = "limit", required = false) + limit: Int? ): List { val validLimit = limit ?: 500 if (validLimit !in 1..1000) @@ -178,10 +504,66 @@ class MarketController( } @GetMapping("/ticker/{duration:24h|7d|1M}") + @Operation( + summary = "Get price change tickers", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Validation: +- `duration` must be one of: `24h`, `7d`, `1M`. + +Behavior: +- If `symbol` is omitted, all symbols are returned. +- If `quote` is provided, results are filtered by quote currency. + +Source of values: +- Quote values should come from `/opex/v1/market/currencyInfo/quotes`. + +Response body: +- Array of price change data. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Price changes returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = PriceChange::class)) + ) + ] + ) + ] + ) suspend fun priceChange( - @PathVariable duration: String, - @RequestParam(required = false) symbol: String?, - @RequestParam(required = false) quote: String? + @Parameter( + name = "duration", + description = "Ticker duration.", + required = true, + schema = Schema(type = "string", allowableValues = ["24h", "7d", "1M"]), + example = "24h" + ) + @PathVariable("duration") + duration: String, + + @Parameter( + name = "symbol", + description = "Optional market symbol. If omitted, all symbols are returned.", + required = false, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol", required = false) + symbol: String?, + + @Parameter( + name = "quote", + description = "Optional quote currency filter.", + required = false, + example = "USDT" + ) + @RequestParam(name = "quote", required = false) + quote: String? ): List { if (!validDurations.contains(duration)) OpexError.InvalidPriceChangeDuration.exception() @@ -205,27 +587,159 @@ class MarketController( } @GetMapping("/ticker/price") - suspend fun priceTicker(@RequestParam(required = false) symbol: String?): List { + @Operation( + summary = "Get price ticker", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Behavior: +- If `symbol` is omitted, prices for all available symbols are returned. + +Response body: +- Array of price ticker data. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Price ticker returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = PriceTicker::class)) + ) + ] + ) + ] + ) + suspend fun priceTicker( + @Parameter( + name = "symbol", + description = "Optional market symbol. If omitted, all available symbols are returned.", + required = false, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol", required = false) + symbol: String? + ): List { return marketDataProxy.lastPrice(symbol) } @GetMapping("/currencyInfo/quotes") + @Operation( + summary = "Get quote currencies", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Source of values: +- Returned values can be used as the `quote` filter in ticker endpoints. + +Response body: +- Array of quote currency strings. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Quote currencies returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(type = "string")) + ) + ] + ) + ] + ) suspend fun getQuoteCurrencies(): List { return walletProxy.getQuoteCurrencies().map { it.currency } } @GetMapping("/klines") + @Operation( + summary = "Get klines", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Validation: +- `interval` must match one of the supported interval labels. +- `limit` must be between `1` and `1000`. + +Behavior: +- `limit` defaults to `500` when omitted. +- `startTime` and `endTime` are Unix timestamps in milliseconds. +- Response rows are nested arrays in candlestick order. + +Response body: +- Nested array of kline rows. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Klines returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(type = "array")) + ) + ] + ) + ] + ) suspend fun klines( - @RequestParam + @Parameter( + name = "symbol", + description = "Market symbol.", + required = true, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol") symbol: String, - @RequestParam + + @Parameter( + name = "interval", + description = "Candlestick interval label.", + required = true, + schema = Schema( + type = "string", + allowableValues = [ + "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", + "24h", "1d", "3d", "7d", "1w", "1M", "3M", "1Y" + ] + ), + example = "1h" + ) + @RequestParam(name = "interval") interval: String, - @RequestParam(required = false) + + @Parameter( + name = "startTime", + description = "Optional start time as Unix timestamp in milliseconds.", + required = false, + schema = Schema(type = "integer", format = "int64") + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, - @RequestParam(required = false) + + @Parameter( + name = "endTime", + description = "Optional end time as Unix timestamp in milliseconds.", + required = false, + schema = Schema(type = "integer", format = "int64") + ) + @RequestParam(name = "endTime", required = false) endTime: Long?, - @RequestParam(required = false) - limit: Int? // Default 500; max 1000. + + @Parameter( + name = "limit", + description = "Optional kline limit. Defaults to 500. Valid range: 1..1000.", + required = false, + schema = Schema(type = "integer", format = "int32", defaultValue = "500", minimum = "1", maximum = "1000"), + example = "500" + ) + @RequestParam(name = "limit", required = false) + limit: Int? ): List> { val validLimit = limit ?: 500 if (validLimit !in 1..1000) @@ -257,24 +771,99 @@ class MarketController( } @GetMapping("/basic-data") + @Operation( + summary = "Get basic market data", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Response body: +- Basic market data. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Basic market data returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = MarketBasicData::class) + ) + ] + ) + ] + ) suspend fun getBasicData(): MarketBasicData { val quoteCurrencies = walletProxy.getQuoteCurrencies() return MarketBasicData( - (quoteCurrencies.map { it.currency }), - (quoteCurrencies.filter { it.isReference }.map { it.currency }), + quoteCurrencies.map { it.currency }, + quoteCurrencies.filter { it.isReference }.map { it.currency }, userActivityReferenceCurrency - ) } @GetMapping("/withdraw-limits") + @Operation( + summary = "Get withdraw limits", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Response body: +- Array of withdraw limit configs. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Withdraw limits returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = WithdrawLimitConfig::class)) + ) + ] + ) + ] + ) suspend fun getWithdrawLimits(): List { return accountantProxy.getWithdrawLimitConfigs() } @GetMapping("/gateway/{gatewayUuid}/terminal") + @Operation( + summary = "Get gateway terminals", + description = """ +Security: +- Public endpoint. No Bearer token is required. + +Source of values: +- `gatewayUuid` should come from currency gateway data. + +Response body: +- Array of terminal commands, or null if no data is returned. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "Gateway terminals returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = TerminalCommand::class)) + ) + ] + ) + ] + ) suspend fun getGatewayTerminal( - @PathVariable("gatewayUuid") gatewayUuid: String, + @Parameter( + name = "gatewayUuid", + description = "Gateway UUID.", + required = true, + example = "ofg-uuid-sample" + ) + @PathVariable("gatewayUuid") + gatewayUuid: String ): List? { return walletProxy.getGatewayTerminal(gatewayUuid) } @@ -285,4 +874,4 @@ class MarketController( limit < 1 -> 1 else -> limit } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/OrderController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/OrderController.kt index db9cf0c90..fc80f91ed 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/OrderController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/OrderController.kt @@ -10,7 +10,15 @@ import co.nilin.opex.api.ports.opex.util.* import co.nilin.opex.common.OpexError import co.nilin.opex.common.security.jwtAuthentication import co.nilin.opex.common.security.tokenValue -import io.swagger.annotations.ApiParam +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @@ -21,33 +29,144 @@ import java.util.* @RestController @RequestMapping("/opex/v1/order") +@Tag( + name = "Order", + description = "Create, cancel, query, and list authenticated user's orders." +) class OrderController( val queryHandler: MarketUserDataProxy, val matchingGatewayProxy: MatchingGatewayProxy, ) { + @PostMapping + @Operation( + summary = "Create order", + description = """ +Security: +- Bearer user-token is required. +- Required permission: PERM_order:write. + +Validation: +- symbol, side, and type are required. +- side values: BUY, SELL. +- type values: LIMIT, MARKET, STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, TAKE_PROFIT_LIMIT, LIMIT_MAKER. +- timeInForce values: GTC, IOC, FOK. +- LIMIT(*) requires price, quantity, and timeInForce. +- MARKET requires quantity or quoteOrderQty. +- STOP_LOSS requires quantity and stopPrice. +- STOP_LOSS_LIMIT requires price, quantity, stopPrice, and timeInForce. +- TAKE_PROFIT requires quantity and stopPrice. +- TAKE_PROFIT_LIMIT requires price, quantity, stopPrice, and timeInForce. +- LIMIT_MAKER requires price and quantity. + +Behavior: +- Optional parameters that are not applicable to the selected order type should be omitted. +- Do not send the literal string "null" for optional numeric parameters. + +Response body: +- NewOrderResponse. + """, + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Order created successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = NewOrderResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. No response body.", + content = [Content()] + ) + ] + ) suspend fun createNewOrder( - @RequestParam - symbol: String, - @RequestParam - side: OrderSide, - @RequestParam - type: OrderType, - @RequestParam(required = false) - timeInForce: TimeInForce?, - @RequestParam(required = false) - quantity: BigDecimal?, - @RequestParam(required = false) - quoteOrderQty: BigDecimal?, - @RequestParam(required = false) - price: BigDecimal?, - @ApiParam(value = "Used with STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, and TAKE_PROFIT_LIMIT orders.") - @RequestParam(required = false) - stopPrice: BigDecimal?, + @Parameter( + name = "symbol", + description = "Trading pair symbol.", + required = true, + `in` = ParameterIn.QUERY, + example = "BTC_USDT" + ) + @RequestParam symbol: String, + + @Parameter( + name = "side", + description = "Order side. Values: BUY, SELL.", + required = true, + `in` = ParameterIn.QUERY, + schema = Schema(implementation = OrderSide::class) + ) + @RequestParam side: OrderSide, + + @Parameter( + name = "type", + description = "Order type. Values: LIMIT, MARKET, STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, TAKE_PROFIT_LIMIT, LIMIT_MAKER.", + required = true, + `in` = ParameterIn.QUERY, + schema = Schema(implementation = OrderType::class) + ) + @RequestParam type: OrderType, + + @Parameter( + name = "timeInForce", + description = "Optional time-in-force. Values: GTC, IOC, FOK. Required for LIMIT, STOP_LOSS_LIMIT, and TAKE_PROFIT_LIMIT orders.", + required = false, + `in` = ParameterIn.QUERY, + schema = Schema(implementation = TimeInForce::class) + ) + @RequestParam(required = false) timeInForce: TimeInForce?, + + @Parameter( + name = "quantity", + description = "Optional base quantity. Required for LIMIT, STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, TAKE_PROFIT_LIMIT, and LIMIT_MAKER orders. For MARKET orders, quantity or quoteOrderQty must be provided.", + required = false, + `in` = ParameterIn.QUERY, + example = "0.001" + ) + @RequestParam(required = false) quantity: BigDecimal?, + + @Parameter( + name = "quoteOrderQty", + description = "Optional quote quantity for MARKET orders when quantity is not provided.", + required = false, + `in` = ParameterIn.QUERY, + example = "10.0" + ) + @RequestParam(required = false) quoteOrderQty: BigDecimal?, + + @Parameter( + name = "price", + description = "Optional order price. Required for LIMIT, STOP_LOSS_LIMIT, TAKE_PROFIT_LIMIT, and LIMIT_MAKER orders.", + required = false, + `in` = ParameterIn.QUERY, + example = "10.0" + ) + @RequestParam(required = false) price: BigDecimal?, + + @Parameter( + name = "stopPrice", + description = "Optional stop price. Required for STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, and TAKE_PROFIT_LIMIT orders.", + required = false, + `in` = ParameterIn.QUERY, + example = "9.5" + ) + @RequestParam(required = false) stopPrice: BigDecimal?, + + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): NewOrderResponse { validateNewOrderParams(type, price, quantity, timeInForce, stopPrice, quoteOrderQty) - matchingGatewayProxy.createNewOrder( securityContext.jwtAuthentication().name, symbol, @@ -63,22 +182,85 @@ class OrderController( } @PutMapping + @Operation( + summary = "Cancel order", + description = """ +Security: +- Bearer user-token is required. +- Required permission: PERM_order:write. + +Validation: +- symbol is required. +- At least one lookup identifier is required: orderId or origClientOrderId. + +Behavior: +- Already canceled orders return a canceled response. +- Rejected, expired, or filled orders cannot be canceled. + +Response body: +- CancelOrderResponse. + """, + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Order canceled successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = CancelOrderResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. No response body.", + content = [Content()] + ) + ] + ) suspend fun cancelOrder( + @Parameter(hidden = true) principal: Principal, - @RequestParam - symbol: String, - @RequestParam(required = false) - orderId: Long?, - @RequestParam(required = false) - origClientOrderId: String?, + + @Parameter( + name = "symbol", + description = "Trading pair symbol.", + required = true, + `in` = ParameterIn.QUERY, + example = "BTC_USDT" + ) + @RequestParam symbol: String, + + @Parameter( + name = "orderId", + description = "Optional numeric order ID. Required when origClientOrderId is not provided.", + required = false, + `in` = ParameterIn.QUERY, + example = "1" + ) + @RequestParam(required = false) orderId: Long?, + + @Parameter( + name = "origClientOrderId", + description = "Optional original client order ID. Required when orderId is not provided.", + required = false, + `in` = ParameterIn.QUERY, + example = "client-order-id-sample" + ) + @RequestParam(required = false) origClientOrderId: String?, + + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): CancelOrderResponse { - if (orderId == null && origClientOrderId == null) - throw OpexError.BadRequest.exception("'orderId' or 'origClientOrderId' must be sent") - + if (orderId == null && origClientOrderId == null) throw OpexError.BadRequest.exception("'orderId' or 'origClientOrderId' must be sent") val order = queryHandler.queryOrder(principal, symbol, orderId, origClientOrderId) ?: throw OpexError.OrderNotFound.exception() - val response = CancelOrderResponse( symbol, origClientOrderId, @@ -94,13 +276,9 @@ class OrderController( order.type.asOrderType(), order.direction.asOrderSide() ) - - if (order.status == OrderStatus.CANCELED) - return response - + if (order.status == OrderStatus.CANCELED) return response if (order.status.equalsAny(OrderStatus.REJECTED, OrderStatus.EXPIRED, OrderStatus.FILLED)) throw OpexError.CancelOrderNotAllowed.exception() - matchingGatewayProxy.cancelOrder( order.ouid, principal.name, @@ -112,14 +290,68 @@ class OrderController( } @GetMapping + @Operation( + summary = "Get order", + description = """ +Security: +- Bearer user-token is required. + +Validation: +- symbol is required. +- At least one lookup identifier should be provided: orderId or origClientOrderId. + +Response body: +- QueryOrderResponse. + """, + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Order returned successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = QueryOrderResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. No response body.", + content = [Content()] + ) + ] + ) suspend fun queryOrder( + @Parameter(hidden = true) principal: Principal, - @RequestParam - symbol: String, - @RequestParam(required = false) - orderId: Long?, - @RequestParam(required = false) - origClientOrderId: String?, + + @Parameter( + name = "symbol", + description = "Trading pair symbol.", + required = true, + `in` = ParameterIn.QUERY, + example = "BTC_USDT" + ) + @RequestParam symbol: String, + + @Parameter( + name = "orderId", + description = "Optional numeric order ID.", + required = false, + `in` = ParameterIn.QUERY, + example = "1" + ) + @RequestParam(required = false) orderId: Long?, + + @Parameter( + name = "origClientOrderId", + description = "Optional original client order ID.", + required = false, + `in` = ParameterIn.QUERY, + example = "client-order-id-sample" + ) + @RequestParam(required = false) origClientOrderId: String?, ): QueryOrderResponse { return queryHandler.queryOrder(principal, symbol, orderId, origClientOrderId) ?.asQueryOrderResponse() @@ -128,12 +360,59 @@ class OrderController( } @GetMapping("/open") + @Operation( + summary = "List open orders", + description = """ +Security: +- Bearer user-token is required. + +Behavior: +- symbol is optional. When omitted, open orders are returned without symbol filtering. +- limit is optional. + +Response body: +- Array of QueryOrderResponse. + """, + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Open orders returned successfully.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = QueryOrderResponse::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. No response body.", + content = [Content()] + ) + ] + ) suspend fun fetchOpenOrders( + @Parameter(hidden = true) principal: Principal, - @RequestParam(required = false) - symbol: String?, - @RequestParam(required = false) - limit: Int? + + @Parameter( + name = "symbol", + description = "Optional trading pair symbol filter.", + required = false, + `in` = ParameterIn.QUERY, + example = "BTC_USDT" + ) + @RequestParam(required = false) symbol: String?, + + @Parameter( + name = "limit", + description = "Optional maximum number of open orders to return.", + required = false, + `in` = ParameterIn.QUERY, + example = "10" + ) + @RequestParam(required = false) limit: Int? ): List { return queryHandler.openOrders(principal, symbol, limit).map { it.asQueryOrderResponse().apply { symbol?.let { s -> this.symbol = s } } @@ -156,10 +435,8 @@ class OrderController( } OrderType.MARKET -> { - if (quantity == null) - checkDecimal(quoteOrderQty, "quoteOrderQty") - else - checkDecimal(quantity, "quantity") + if (quantity == null) checkDecimal(quoteOrderQty, "quoteOrderQty") + else checkDecimal(quantity, "quantity") } OrderType.STOP_LOSS -> { @@ -224,5 +501,4 @@ class OrderController( status.isWorking(), quoteQuantity ) - -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileAdminController.kt index 4606f77dc..956bf60ba 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileAdminController.kt @@ -4,18 +4,60 @@ import co.nilin.opex.api.core.inout.* import co.nilin.opex.api.core.spi.ProfileProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/opex/v1/admin/profile") +@Tag(name = "Profile Admin", description = "Admin profile and approval request operations.") class ProfileAdminController(private val profileProxy: ProfileProxy) { @PostMapping + @Operation( + summary = "Get profiles", + description = """POST /opex/v1/admin/profile. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- status: CREATED, CONTACT_INFO_COMPLETED, PROFILE_COMPLETED, SYSTEM_APPROVED, PENDING_ADMIN_APPROVAL, ADMIN_REJECTED, ADMIN_APPROVED. +- kycLevel: LEVEL_1, LEVEL_2, LEVEL_3. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = Profile::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getProfiles( @RequestBody profileRequest: ProfileRequest, - @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext ): List { return profileProxy.getProfiles( securityContext.jwtAuthentication().tokenValue(), @@ -24,18 +66,91 @@ class ProfileAdminController(private val profileProxy: ProfileProxy) { } @GetMapping("/{uuid}") + @Operation( + summary = "Get profile", + description = """GET /opex/v1/admin/profile/{uuid}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- status: CREATED, CONTACT_INFO_COMPLETED, PROFILE_COMPLETED, SYSTEM_APPROVED, PENDING_ADMIN_APPROVAL, ADMIN_REJECTED, ADMIN_APPROVED. +- kycLevel: LEVEL_1, LEVEL_2, LEVEL_3. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = Profile::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getProfile( + @Parameter( + name = "uuid", + description = "User/profile/terminal UUID depending on the endpoint context.", + required = true + ) @PathVariable uuid: String, - @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext ): Profile { return profileProxy.getProfileAdmin(securityContext.jwtAuthentication().tokenValue(), uuid) } @GetMapping("/history/{uuid}") + @Operation( + summary = "Get profile history", + description = """GET /opex/v1/admin/profile/history/{uuid}. +Behavior: `limit` defaults to 10 and `offset` defaults to 0 when omitted. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- status: CREATED, CONTACT_INFO_COMPLETED, PROFILE_COMPLETED, SYSTEM_APPROVED, PENDING_ADMIN_APPROVAL, ADMIN_REJECTED, ADMIN_APPROVED. +- kycLevel: LEVEL_1, LEVEL_2, LEVEL_3. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = ProfileHistory::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getProfileHistory( + @Parameter(name = "uuid", description = "User UUID depending on the endpoint context.", required = true) @PathVariable uuid: String, - @RequestParam offset: Int?, @RequestParam limit: Int?, - @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "offset", description = "Optional page offset.", required = false) + @RequestParam offset: Int?, + @Parameter(name = "limit", description = "Optional page size.", required = false) + @RequestParam limit: Int?, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext ): List { return profileProxy.getProfileHistory( securityContext.jwtAuthentication().tokenValue(), @@ -46,24 +161,124 @@ class ProfileAdminController(private val profileProxy: ProfileProxy) { } @PostMapping("/approval-requests") + @Operation( + summary = "Get approval requests", + description = """POST /opex/v1/admin/profile/approval-requests. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- status: CREATED, CONTACT_INFO_COMPLETED, PROFILE_COMPLETED, SYSTEM_APPROVED, PENDING_ADMIN_APPROVAL, ADMIN_REJECTED, ADMIN_APPROVED. +- kycLevel: LEVEL_1, LEVEL_2, LEVEL_3. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = ProfileApprovalAdminResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getApprovalRequests( @RequestBody request: ProfileApprovalRequestFilter, - @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext ): List { return profileProxy.getProfileApprovalRequests(securityContext.jwtAuthentication().tokenValue(), request) } @GetMapping("/approval-request/{id}") + @Operation( + summary = "Get approval request", + description = """GET /opex/v1/admin/profile/approval-request/{id}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- status: CREATED, CONTACT_INFO_COMPLETED, PROFILE_COMPLETED, SYSTEM_APPROVED, PENDING_ADMIN_APPROVAL, ADMIN_REJECTED, ADMIN_APPROVED. +- kycLevel: LEVEL_1, LEVEL_2, LEVEL_3. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ProfileApprovalAdminResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getApprovalRequest( + @Parameter(name = "id", description = "Numeric resource ID.", required = true) @PathVariable("id") id: Long, - @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext ): ProfileApprovalAdminResponse { return profileProxy.getProfileApprovalRequest(securityContext.jwtAuthentication().tokenValue(), id) } @PutMapping("/approval-request") + @Operation( + summary = "Update approval request status", + description = """PUT /opex/v1/admin/profile/approval-request. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- status: CREATED, CONTACT_INFO_COMPLETED, PROFILE_COMPLETED, SYSTEM_APPROVED, PENDING_ADMIN_APPROVAL, ADMIN_REJECTED, ADMIN_APPROVED. +- kycLevel: LEVEL_1, LEVEL_2, LEVEL_3. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ProfileApprovalAdminResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun updateApprovalRequestStatus( @RequestBody changeRequestStatusBody: UpdateApprovalRequestBody, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): ProfileApprovalAdminResponse { return profileProxy.updateProfileApprovalRequest( @@ -71,4 +286,4 @@ class ProfileAdminController(private val profileProxy: ProfileProxy) { changeRequestStatusBody ) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileController.kt index 9679d60e4..6ee993593 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileController.kt @@ -6,6 +6,13 @@ import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.toProfileApprovalRequestUserResponse import co.nilin.opex.api.ports.opex.util.toProfileResponse import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @@ -13,18 +20,76 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/opex/v1/profile") +@Tag( + name = "Profile", + description = "Authenticated user profile operations." +) class ProfileController( - val profileProxy: ProfileProxy, + val profileProxy: ProfileProxy ) { @GetMapping("/personal-data") - suspend fun getProfile(@CurrentSecurityContext securityContext: SecurityContext): ProfileResponse { + @Operation( + summary = "Get profile", + description = """GET /opex/v1/profile/personal-data. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ProfileResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun getProfile( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): ProfileResponse { return profileProxy.getProfile(securityContext.jwtAuthentication().tokenValue()).toProfileResponse() } @PutMapping("/completion") + @Operation( + summary = "Complete profile", + description = """PUT /opex/v1/profile/completion. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ProfileResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun completeProfile( @RequestBody completeProfileRequest: CompleteProfileRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): ProfileResponse? { return profileProxy.completeProfile(securityContext.jwtAuthentication().tokenValue(), completeProfileRequest) @@ -32,16 +97,57 @@ class ProfileController( } @PostMapping("/contact/update/otp-request") + @Operation( + summary = "Request contact update", + description = """POST /opex/v1/profile/contact/update/otp-request. +Behavior: Starts contact update flow and returns OTP delivery information. +Security: Bearer user-token required. Requires authenticated user JWT. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TempOtpResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun requestContactUpdate( @RequestBody request: ContactUpdateRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): TempOtpResponse { return profileProxy.requestContactUpdate(securityContext.jwtAuthentication().tokenValue(), request) } @PatchMapping("/contact/update/otp-verification") + @Operation( + summary = "Confirm contact update", + description = """PATCH /opex/v1/profile/contact/update/otp-verification. +Validation: Verification request must contain the OTP value and target contact details required by the request schema. +Security: Bearer user-token required. Requires authenticated user JWT. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun confirmContactUpdate( @RequestBody request: ContactUpdateConfirmRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ) { profileProxy.confirmContactUpdate(securityContext.jwtAuthentication().tokenValue(), request) @@ -49,8 +155,36 @@ class ProfileController( } @GetMapping("/approval-request") - suspend fun getApprovalRequest(@CurrentSecurityContext securityContext: SecurityContext): ProfileApprovalRequestUserResponse { + @Operation( + summary = "Get approval request", + description = """GET /opex/v1/profile/approval-request. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- nationality: IRANIAN, NON_IRANIAN. +- gender: FEMALE, MALE. +- approval request status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ProfileApprovalRequestUserResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun getApprovalRequest( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): ProfileApprovalRequestUserResponse { return profileProxy.getUserProfileApprovalRequest(securityContext.jwtAuthentication().tokenValue()) .toProfileApprovalRequestUserResponse() } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageAdminController.kt index 3481f3ac5..bd0484223 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageAdminController.kt @@ -3,47 +3,205 @@ package co.nilin.opex.api.ports.opex.controller import co.nilin.opex.api.core.spi.StorageProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.http.codec.multipart.FilePart import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* + +@Schema(name = "StorageUploadMultipartRequest") +data class StorageUploadMultipartRequest( + + @field:Schema( + description = "File to upload.", + type = "string", + format = "binary" + ) + val file: String? = null +) + @RestController @RequestMapping("/opex/v1/admin/storage") +@Tag(name = "Storage Admin", description = "Admin storage upload, download, and delete operations.") class StorageAdminController( private val storageProxy: StorageProxy, @Value("\${app.base.url}") private val appBaseUrl: String ) { @GetMapping + @Operation( + summary = "Download", + description = """GET /opex/v1/admin/storage. +Behavior: Returns binary file bytes for the requested bucket/key. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- isPublic: true, false.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/octet-stream", + schema = Schema(type = "string", format = "binary") + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun download( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "bucket", description = "Storage bucket name.", required = true) @RequestParam("bucket") bucket: String, - @RequestParam("key") key: String, + @Parameter(name = "key", description = "Storage object key.", required = true) + @RequestParam("key") key: String ): ResponseEntity { return storageProxy.adminDownload(securityContext.jwtAuthentication().tokenValue(), bucket, key) } - @PostMapping + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @Operation( + summary = "Upload", + description = """POST /opex/v1/admin/storage. +Behavior: Multipart upload. `file` is required. `isPublic` defaults to false when omitted. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Validation: Send multipart/form-data with a `file` part. `bucket` and `key` identify the target object. +Allowed values: +- isPublic: true, false.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = [ + Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = Schema(implementation = StorageUploadMultipartRequest::class) + ) + ] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "text/plain", + schema = Schema(type = "string") + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) + suspend fun upload( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestParam("bucket") bucket: String, - @RequestParam("key") key: String, - @RequestPart("file") file: FilePart, - @RequestParam("isPublic") isPublic: Boolean? = false, + + @Parameter( + name = "bucket", + description = "Storage bucket name.", + required = true + ) + @RequestParam("bucket") + bucket: String, + + @Parameter( + name = "key", + description = "Storage object key.", + required = true + ) + @RequestParam("key") + key: String, + + @Parameter( + name = "file", + description = "File to upload.", + required = true, + schema = Schema(type = "string", format = "binary") + ) + @RequestPart("file") + file: FilePart, + + @Parameter( + name = "isPublic", + description = "Whether the uploaded object should be publicly accessible.", + required = false, + schema = Schema(type = "boolean", defaultValue = "false") + ) + @RequestParam("isPublic", required = false) + isPublic: Boolean? = false ): String { - storageProxy.adminUpload(securityContext.jwtAuthentication().tokenValue(), bucket, key, file, isPublic) + storageProxy.adminUpload( + securityContext.jwtAuthentication().tokenValue(), + bucket, + key, + file, + isPublic + ) return "$appBaseUrl/opex/v1/storage?bucket=$bucket&key=$key" } @DeleteMapping + @Operation( + summary = "Delete", + description = """DELETE /opex/v1/admin/storage. +Behavior: Deletes the object identified by bucket/key. Response has no body. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- isPublic: true, false.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun delete( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "bucket", description = "Storage bucket name.", required = true) @RequestParam("bucket") bucket: String, - @RequestParam("key") key: String, + @Parameter(name = "key", description = "Storage object key.", required = true) + @RequestParam("key") key: String ) { storageProxy.adminDelete(securityContext.jwtAuthentication().tokenValue(), bucket, key) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageController.kt index dd532ded9..04d4e8c54 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageController.kt @@ -1,6 +1,12 @@ package co.nilin.opex.api.ports.opex.controller import co.nilin.opex.api.core.spi.StorageProxy +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -9,15 +15,35 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/opex/v1/storage") +@Tag(name = "Storage", description = "Public storage download operations.") class StorageController( - private val storageProxy: StorageProxy, + private val storageProxy: StorageProxy ) { @GetMapping + @Operation( + summary = "Download", + description = """GET /opex/v1/storage. +Behavior: Public file download for the requested bucket/key. Returns binary file bytes. +Security: Public endpoint. No Bearer token is required. +""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/octet-stream", + schema = Schema(type = "string", format = "binary") + )] + ) + ] + ) suspend fun download( + @Parameter(name = "bucket", description = "Storage bucket name.", required = true) @RequestParam("bucket") bucket: String, - @RequestParam("key") key: String, + @Parameter(name = "key", description = "Storage object key.", required = true) + @RequestParam("key") key: String ): ResponseEntity { return storageProxy.publicDownload(bucket, key) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SwapController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SwapController.kt index 29330c9dd..52b2779fc 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SwapController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SwapController.kt @@ -6,28 +6,96 @@ import co.nilin.opex.api.core.inout.TransferResult import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/opex/v1/swap") +@Tag(name = "Swap", description = "Authenticated swap reserve and finalize operations.") class SwapController( - val walletProxy: WalletProxy, + val walletProxy: WalletProxy ) { @PostMapping("/reserve") + @Operation( + summary = "Reserve", + description = """POST /opex/v1/swap/reserve. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- sourceWalletType and destWalletType where used: MAIN, EXCHANGE, CASHOUT. +Source of values: +- sourceSymbol and destSymbol are server-provided currency symbols.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ReservedTransferResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun reserve( @RequestBody request: TransferReserveRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): ReservedTransferResponse { return walletProxy.reserveSwap(securityContext.jwtAuthentication().tokenValue(), request) } @PostMapping("/finalize/{reserveUuid}") + @Operation( + summary = "Finalize transfer", + description = """POST /opex/v1/swap/finalize/{reserveUuid}. +Behavior: `description` and `transferRef` are optional metadata for finalizing a reserved swap. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- sourceWalletType and destWalletType where used: MAIN, EXCHANGE, CASHOUT. +Source of values: +- sourceSymbol and destSymbol are server-provided currency symbols.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TransferResult::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun finalizeTransfer( + @Parameter( + name = "reserveUuid", + description = "Swap reserve UUID returned by reserve endpoint.", + required = true + ) @PathVariable reserveUuid: String, + @Parameter(name = "description", description = "Optional transfer description.", required = false) @RequestParam description: String?, + @Parameter(name = "transferRef", description = "Optional external transfer reference.", required = false) @RequestParam transferRef: String?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): TransferResult { return walletProxy.finalizeSwap( @@ -37,4 +105,4 @@ class SwapController( transferRef ) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TerminalAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TerminalAdminController.kt index 932733f64..09cc709e7 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TerminalAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TerminalAdminController.kt @@ -2,20 +2,58 @@ package co.nilin.opex.api.ports.opex.controller import co.nilin.opex.api.core.inout.CurrencyGatewayCommand import co.nilin.opex.api.core.inout.TerminalCommand +import co.nilin.opex.api.core.inout.TerminalUpdateCommand import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/opex/v1/admin/terminal") +@Tag(name = "Terminal Admin", description = "Admin terminal and terminal-gateway assignment operations.") class TerminalAdminController( - private val walletProxy: WalletProxy, + private val walletProxy: WalletProxy ) { @PostMapping + @Operation( + summary = "Register terminal", + description = """POST /opex/v1/admin/terminal. +Security: Bearer admin-token required. Required authority: ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TerminalCommand::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun registerTerminal( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody body: TerminalCommand ): TerminalCommand? { return walletProxy.saveTerminal(securityContext.jwtAuthentication().tokenValue(), body) @@ -23,50 +61,211 @@ class TerminalAdminController( @PutMapping("/{uuid}") + @Operation( + summary = "Update terminal", + description = """PUT /opex/v1/admin/terminal/{terminalUuid}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TerminalCommand::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun updateTerminal( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "uuid", description = "Terminal UUID.", required = true) @PathVariable("uuid") terminalUuid: String, - @RequestBody body: TerminalCommand + @RequestBody body: TerminalUpdateCommand ): TerminalCommand? { return walletProxy.updateTerminal(securityContext.jwtAuthentication().tokenValue(), terminalUuid, body) } @DeleteMapping("/{uuid}") + @Operation( + summary = "Delete terminal", + description = """DELETE /opex/v1/admin/terminal/{terminalUuid}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun deleteTerminal( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @PathVariable("uuid") terminalUuid: String, + @Parameter(name = "uuid", description = "Terminal UUID.", required = true) + @PathVariable("uuid") terminalUuid: String ) { walletProxy.deleteTerminal(securityContext.jwtAuthentication().tokenValue(), terminalUuid) } @GetMapping + @Operation( + summary = "Get terminal", + description = """GET /opex/v1/admin/terminal. +Security: Bearer admin-token required. Required authority: ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = TerminalCommand::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTerminal( - @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext ): List? { return walletProxy.getTerminals(securityContext.jwtAuthentication().tokenValue()) } @GetMapping("/{uuid}") + @Operation( + summary = "Get terminal", + description = """GET /opex/v1/admin/terminal/{terminalUuid}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TerminalCommand::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTerminal( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @PathVariable("uuid") terminalUuid: String, + @Parameter(name = "uuid", description = "Terminal UUID depending on the endpoint context.", required = true) + @PathVariable("uuid") terminalUuid: String ): TerminalCommand? { return walletProxy.getTerminal(securityContext.jwtAuthentication().tokenValue(), terminalUuid) } @GetMapping("/{uuid}/gateway") + @Operation( + summary = "Get gateway(s) which the terminal is assigned to", + description = """GET /opex/v1/admin/terminal/{gatewayUuid}/gateway. +Security: Bearer admin-token required. Required authority: ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = CurrencyGatewayCommand::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getGatewayTerminal( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @PathVariable("uuid") terminalUuid: String, + @Parameter(name = "uuid", description = "Terminal UUID.", required = true) + @PathVariable("uuid") terminalUuid: String ): List? { return walletProxy.getAssignedGatewayToTerminal(securityContext.jwtAuthentication().tokenValue(), terminalUuid) } @PostMapping("/gateway/{uuid}") + @Operation( + summary = "Assign terminal to gateway", + description = """POST /opex/v1/admin/terminal/gateway/{gatewayUuid}. +Validation: Request body is a raw JSON array of terminal UUID strings. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun assignTerminalToGateway( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "uuid", description = "Gateway UUID.", required = true) @PathVariable("uuid") gatewayUuid: String, - @RequestBody terminal: List, + @RequestBody terminal: List ) { return walletProxy.assignTerminalsToGateway( securityContext.jwtAuthentication().tokenValue(), @@ -76,10 +275,33 @@ class TerminalAdminController( } @DeleteMapping("/gateway/{uuid}") + @Operation( + summary = "Revoke terminal from gateway", + description = """DELETE /opex/v1/admin/terminal/gateway/{gatewayUuid}. +Validation: Request body is a raw JSON array of terminal UUID strings to revoke from the gateway. +Security: Bearer admin-token required. Required authority: ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun revokeTerminalFromGateway( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "uuid", description = "Gateway UUID.", required = true) @PathVariable("uuid") gatewayUuid: String, - @RequestBody terminal: List, + @RequestBody terminal: List ) { return walletProxy.revokeTerminalsToGateway( securityContext.jwtAuthentication().tokenValue(), diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TransactionAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TransactionAdminController.kt index c55f72079..d28fb924a 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TransactionAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TransactionAdminController.kt @@ -4,6 +4,14 @@ import co.nilin.opex.api.core.inout.* import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.PostMapping @@ -14,20 +22,83 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/opex/v1/admin/transactions") +@Tag(name = "Transactions Admin", description = "Admin transaction history and summary operations.") class TransactionAdminController( - private val walletProxy: WalletProxy, + private val walletProxy: WalletProxy ) { @PostMapping("/summary") + @Operation( + summary = "Get user transaction history", + description = """POST /opex/v1/admin/transactions/summary. +Security: Bearer admin-token required. Required authority: ROLE_monitoring or ROLE_admin. +Allowed values: +- category: TRADE, DEPOSIT, DEPOSIT_TO, WITHDRAW_FROM, WITHDRAW, FEE, SWAP, REFERRAL_COMMISSION, REFERRAL_KYC_REWARD, REFERENT_COMMISSION, KYC_ACCEPTED_REWARD, SYSTEM. + +""", + + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = UserTransactionHistory::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_monitoring or ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getUserTransactionHistory( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UserTransactionRequest, + @RequestBody request: UserTransactionRequest ): List { return walletProxy.getUserTransactionHistoryForAdmin(securityContext.jwtAuthentication().tokenValue(), request) } @PostMapping("/deposits") + @Operation( + summary = "Get deposit transactions", + description = """POST /opex/v1/admin/transactions/deposits. +Security: Bearer admin-token required. Required authority: ROLE_monitoring or ROLE_admin. +Allowed values: +- DepositStatus: PROCESSING, DONE, INVALID +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = DepositAdminResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_monitoring or ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getDepositTransactions( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody request: AdminDepositHistoryRequest ): List { @@ -38,7 +109,35 @@ class TransactionAdminController( } @PostMapping("/withdraws") + @Operation( + summary = "Get withdraw transactions", + description = """POST /opex/v1/admin/transactions/withdraws. +Security: Bearer admin-token required. Required authority: ROLE_monitoring or ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = WithdrawAdminResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_monitoring or ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getWithdrawTransactions( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody request: AdminWithdrawHistoryRequest ): List { @@ -49,9 +148,39 @@ class TransactionAdminController( } @PostMapping("/swaps") + @Operation( + summary = "Get swap transactions", + description = """POST /opex/v1/admin/transactions/swaps. +Security: Bearer admin-token required. Required authority: ROLE_monitoring or ROLE_admin. +Allowed values: +- ReservedStatus: Created, Expired, Committed, +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = SwapAdminResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_monitoring or ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getSwapTransactions( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UserTransactionRequest, + @RequestBody request: UserSwapTransactionRequest ): List { return walletProxy.getSwapTransactionsForAdmin( securityContext.jwtAuthentication().tokenValue(), @@ -61,10 +190,38 @@ class TransactionAdminController( // This part is temporary and the structure of fetching trades needs to be fixed. @PostMapping("/trades") + @Operation( + summary = "Get transaction history", + description = """POST /opex/v1/admin/transactions/trades. +Security: Bearer admin-token required. Required authority: ROLE_monitoring or ROLE_admin. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = TradeAdminResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_monitoring or ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTransactionHistory( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: AdminTradeHistoryRequest, + @RequestBody request: AdminTradeHistoryRequest ): List { return walletProxy.getTradeHistoryForAdmin(securityContext.jwtAuthentication().tokenValue(), request) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt index af11c880f..5a0fe2e95 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt @@ -6,6 +6,15 @@ import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.opex.service.UserActivityAggregationService import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.GetMapping @@ -15,21 +24,91 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/opex/v1/analytics") +@Tag( + name = "User Analytics", + description = "User analytics and user-asset analytics operations." +) class UserAnalyticsController( private val userActivityAggregationService: UserActivityAggregationService, - val walletProxy: WalletProxy, + val walletProxy: WalletProxy ) { @GetMapping("/user-activity") - suspend fun userActivity(@CurrentSecurityContext securityContext: SecurityContext): Map { + @Operation( + summary = "User activity", + description = """GET /opex/v1/analytics/user-activity. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior: +- Returns activity totals for the last 31 days. +- Response object keys are epoch timestamps in milliseconds. +- All date/time values exposed by the API layer are timestamps.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. Map key is epoch timestamp in milliseconds. Map value is daily activity totals.", + content = [Content( + mediaType = "application/json", + schema = Schema(type = "object", additionalPropertiesSchema = ActivityTotals::class), + examples = [ExampleObject( + name = "User activity response", + value = """ +{ + "1715731200000": { + "totalBalance": 1000.50, + "totalWithdraw": 20.00, + "totalDeposit": 200.00, + "totalTrade": 150.00, + "totalOrder": 3 + } +} + """ + )] + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun userActivity( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): Map { val auth = securityContext.jwtAuthentication() return userActivityAggregationService.getLast31DaysUserStats(auth.tokenValue(), auth.name) } @GetMapping("/users-detail-assets") + @Operation( + summary = "Get user details assets", + description = """GET /opex/v1/analytics/users-detail-assets. +Security: Public endpoint. No Bearer token is required. + +Behavior: +- limit defaults to 10 when omitted. +- offset defaults to 0 when omitted. + +""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = UserDetailAssetsSnapshot::class)) + )] + ) + ] + ) suspend fun getUserDetailsAssets( + @Parameter(name = "limit", description = "Optional page size.", required = false) @RequestParam limit: Int?, - @RequestParam offset: Int?, + @Parameter(name = "offset", description = "Optional page offset.", required = false) + @RequestParam offset: Int? ): List { return walletProxy.getUsersDetailAssets(limit ?: 10, offset ?: 0) } diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserConfigController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserConfigController.kt new file mode 100644 index 000000000..bf3fb1280 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserConfigController.kt @@ -0,0 +1,201 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.UpdateUserConfigRequest +import co.nilin.opex.api.core.inout.UserWebConfig +import co.nilin.opex.api.core.spi.ConfigProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import co.nilin.opex.common.data.WebConfig +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/opex/v1") +@Tag( + name = "Config", + description = "Web and user configuration operations." +) +class UserConfigController(private val configProxy: ConfigProxy) { + + @GetMapping("/web/config") + @Operation( + summary = "Get web config", + description = """GET /opex/v1/web/config. +Security: Public endpoint. No Bearer token is required. + +Allowed values: +- language: EN, FA, AR, UZ. +- calender: JALALI, HIJRI, GREGORIAN. +- theme: DARK, LIGHT, SYSTEM.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = WebConfig::class))] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]) + ] + ) + suspend fun getWebConfig(): WebConfig { + return configProxy.getWebConfig() + } + + @GetMapping("/user/config") + @Operation( + summary = "Get user config", + description = """GET /opex/v1/user/config. +Security: Bearer user-token required. Requires authenticated user JWT. + +Allowed values: +- language: EN, FA, AR, UZ. +- calender: JALALI, HIJRI, GREGORIAN. +- theme: DARK, LIGHT, SYSTEM.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = UserWebConfig::class) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]) + ] + ) + suspend fun getUserConfig( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): UserWebConfig { + return configProxy.getUserConfig(securityContext.jwtAuthentication().tokenValue()) + } + + @PutMapping("/user/config") + @Operation( + summary = "Update user config", + description = """PUT /opex/v1/user/config. +Security: Bearer user-token required. Requires authenticated user JWT. + +Allowed values: +- language: EN, FA, AR, UZ. +- calender: JALALI, HIJRI, GREGORIAN. +- theme: DARK, LIGHT, SYSTEM.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = UpdateUserConfigRequest::class) + )] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = UserWebConfig::class) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]) + ] + ) + suspend fun updateConfig( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: UpdateUserConfigRequest + ): UserWebConfig { + return configProxy.updateUserConfig(securityContext.jwtAuthentication().tokenValue(), request) + } + + @GetMapping("/user/config/pair") + @Operation( + summary = "Get favorite pairs", + description = """GET /opex/v1/user/config/pair. +Security: Bearer user-token required. Requires authenticated user JWT. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(type = "string")) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]) + ] + ) + suspend fun getUserFavoritePair( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): Set { + return configProxy.getUserFavoritePair(securityContext.jwtAuthentication().tokenValue()) + } + + @PostMapping("/user/config/pair/{pair}") + @Operation( + summary = "Add favorite pair", + description = """POST /opex/v1/user/config/pair/{pair}. +Security: Bearer user-token required. Requires authenticated user JWT. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(type = "string")) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]) + ] + ) + suspend fun addUserFavoritePair( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "pair", description = "Market pair symbol.", required = true) + @PathVariable pair: String + ): Set { + return configProxy.addUserFavoritePair(securityContext.jwtAuthentication().tokenValue(), pair) + } + + @DeleteMapping("/user/config/pair/{pair}") + @Operation( + summary = "Remove favorite pair", + description = """DELETE /opex/v1/user/config/pair/{pair}. +Security: Bearer user-token required. Requires authenticated user JWT. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(type = "string")) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]) + ] + ) + suspend fun removeUserFavoritePair( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "pair", description = "Market pair symbol.", required = true) + @PathVariable pair: String + ): Set { + return configProxy.removeUserFavoritePair(securityContext.jwtAuthentication().tokenValue(), pair) + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserDataController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserDataController.kt index 4be17e6f7..155570cfc 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserDataController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserDataController.kt @@ -2,8 +2,15 @@ package co.nilin.opex.api.ports.opex.controller import co.nilin.opex.api.core.inout.UserFee import co.nilin.opex.api.core.spi.AccountantProxy -import co.nilin.opex.common.OpexError import co.nilin.opex.common.utils.Interval +import co.nilin.opex.common.utils.LimitedInterval +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.GetMapping @@ -14,50 +21,151 @@ import java.math.BigDecimal @RestController @RequestMapping("/opex/v1/user/data") +@Tag( + name = "User Exchange Data", + description = "Authenticated user fee, volume, and activity data." +) class UserDataController( - private val accountantProxy: AccountantProxy, + private val accountantProxy: AccountantProxy ) { @GetMapping("/trade/volume") + @Operation( + summary = "Get trade volume by currency", + description = """GET /opex/v1/user/data/trade/volume. +Validation: `interval` must be one of Day, Week, Month, Year. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- interval: Day, Week, Month, Year.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "number"))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTradeVolumeByCurrency( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter( + name = "symbol", + description = "Trading pair or currency symbol, depending on endpoint.", + required = true + ) @RequestParam symbol: String, - @RequestParam interval: Interval + @Parameter( + name = "interval", + description = "Interval. For user data endpoints allowed values are Day, Week, Month, Year. ", + required = true + ) + @RequestParam interval: LimitedInterval ): BigDecimal { - checkValidInterval(interval) + val interval = Interval.valueOf(interval.name) val uuid = securityContext.authentication.name return accountantProxy.getTradeVolumeByCurrency(uuid, symbol, interval) } @GetMapping("/trade/volume/total") + @Operation( + summary = "Get total trade volume value", + description = """GET /opex/v1/user/data/trade/volume/total. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- interval: Day, Week, Month, Year.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "number"))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTotalTradeVolumeValue( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestParam interval: Interval + @Parameter( + name = "interval", + description = "Interval. For user data endpoints allowed values are Day, Week, Month, Year.", + required = true + ) + @RequestParam interval: LimitedInterval ): BigDecimal { - checkValidInterval(interval) + val interval = Interval.valueOf(interval.name) val uuid = securityContext.authentication.name return accountantProxy.getTotalTradeVolumeValue(uuid, interval) } @GetMapping("/fee") - suspend fun getUserFee(@CurrentSecurityContext securityContext: SecurityContext): UserFee { + @Operation( + summary = "Get user fee", + description = """GET /opex/v1/user/data/fee. +Security: Bearer user-token required. Requires authenticated user JWT. +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = UserFee::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun getUserFee( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): UserFee { return accountantProxy.getUserFee(securityContext.authentication.name) } @GetMapping("/withdraw/volume/total") + @Operation( + summary = "Get total withdraw volume value", + description = """GET /opex/v1/user/data/withdraw/volume/total. +Behavior: `interval` is optional. Accepted labels map to Day, Week, Month, Year where supported. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- interval: Day, Week, Month, Year.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTotalWithdrawVolumeValue( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestParam(required = false) interval: String? + @Parameter( + name = "interval", + description = "Interval. For user data endpoints allowed values are Day, Week, Month, Year. ", + required = false + ) + @RequestParam(required = false) interval: LimitedInterval? ): BigDecimal = accountantProxy.getTotalWithdrawVolumeValue( securityContext.authentication.name, - interval?.let(Interval::findByLabel) + interval?.let { Interval.valueOf(interval.name) } ) - - private fun checkValidInterval(interval: Interval) { - if (interval == Interval.Day || interval == Interval.Week || interval == Interval.Month || interval == Interval.Year) - return - throw OpexError.BadRequest.exception() - } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserHistoryController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserHistoryController.kt index cb90e5c3c..9b59872c5 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserHistoryController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserHistoryController.kt @@ -7,26 +7,90 @@ import co.nilin.opex.api.ports.opex.data.OrderDataResponse import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.toResponse import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/opex/v1/user") +@Tag( + name = "User History", + description = """Authenticated user history, summaries, and swap-history operations.""" +) class UserHistoryController( private val marketUserDataProxy: MarketUserDataProxy, private val walletProxy: WalletProxy, ) { @GetMapping("/history/order") + @Operation( + summary = "Get order history", + description = """GET /opex/v1/user/history/order. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` defaults to 10 and `offset` defaults to 0 when omitted. + +Allowed values: +- orderType: LIMIT_ORDER, MARKET_ORDER. +- direction: ASK, BID.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = OrderDataResponse::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getOrderHistory( - @RequestParam symbol: String?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam orderType: MatchingOrderType?, - @RequestParam direction: OrderDirection?, - @RequestParam limit: Int?, - @RequestParam offset: Int?, + @Parameter(name = "symbol", description = "Optional trading pair symbol.", required = false) + @RequestParam(name = "symbol", required = false) symbol: String?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter( + name = "orderType", + description = "Optional order type filter. Allowed values: LIMIT_ORDER, MARKET_ORDER.", + required = false + ) + @RequestParam(name = "orderType", required = false) orderType: MatchingOrderType?, + @Parameter( + name = "direction", + description = "Optional order direction filter. Allowed values: ASK, BID.", + required = false + ) + @RequestParam(name = "direction", required = false) direction: OrderDirection?, + @Parameter(name = "limit", description = "Optional page size. Defaults to 10 when omitted.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(name = "offset", description = "Optional page offset. Defaults to 0 when omitted.", required = false) + @RequestParam(name = "offset", required = false) offset: Int?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return marketUserDataProxy.getOrderHistory( @@ -42,12 +106,56 @@ class UserHistoryController( } @GetMapping("/history/order/count") + @Operation( + summary = "Count order history", + description = """GET /opex/v1/user/history/order/count. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. + +Allowed values: +- orderType: LIMIT_ORDER, MARKET_ORDER. +- direction: ASK, BID.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = Long::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getOrderHistoryCount( - @RequestParam symbol: String?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam orderType: MatchingOrderType?, - @RequestParam direction: OrderDirection?, + @Parameter(name = "symbol", description = "Optional trading pair symbol.", required = false) + @RequestParam(name = "symbol", required = false) symbol: String?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter( + name = "orderType", + description = "Optional order type filter. Allowed values: LIMIT_ORDER, MARKET_ORDER.", + required = false + ) + @RequestParam(name = "orderType", required = false) orderType: MatchingOrderType?, + @Parameter( + name = "direction", + description = "Optional order direction filter. Allowed values: ASK, BID.", + required = false + ) + @RequestParam(name = "direction", required = false) direction: OrderDirection?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): Long { return marketUserDataProxy.getOrderHistoryCount( @@ -61,42 +169,189 @@ class UserHistoryController( } @GetMapping("/history/trade") + @Operation( + summary = "Get trade history", + description = """GET /opex/v1/user/history/trade. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` defaults to 10 and `offset` defaults to 0 when omitted. + +Allowed values: +- direction: ASK, BID.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = Trade::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTradeHistory( - @RequestParam symbol: String?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam direction: OrderDirection?, - @RequestParam limit: Int?, - @RequestParam offset: Int?, + @Parameter(name = "symbol", description = "Optional trading pair symbol.", required = false) + @RequestParam(name = "symbol", required = false) symbol: String?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter( + name = "direction", + description = "Optional trade direction filter. Allowed values: ASK, BID.", + required = false + ) + @RequestParam(name = "direction", required = false) direction: OrderDirection?, + @Parameter(name = "limit", description = "Optional page size. Defaults to 10 when omitted.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(name = "offset", description = "Optional page offset. Defaults to 0 when omitted.", required = false) + @RequestParam(name = "offset", required = false) offset: Int?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return marketUserDataProxy.getTradeHistory( - securityContext.authentication.name, symbol, startTime, endTime, direction, limit ?: 10, offset ?: 0 + securityContext.authentication.name, + symbol, + startTime, + endTime, + direction, + limit ?: 10, + offset ?: 0 ) } @GetMapping("/history/trade/count") + @Operation( + summary = "Count trade history", + description = """GET /opex/v1/user/history/trade/count. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. + +Allowed values: +- direction: ASK, BID.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = Long::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTradeHistoryCount( - @RequestParam symbol: String?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam direction: OrderDirection?, + @Parameter(name = "symbol", description = "Optional trading pair symbol.", required = false) + @RequestParam(name = "symbol", required = false) symbol: String?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter( + name = "direction", + description = "Optional trade direction filter. Allowed values: ASK, BID.", + required = false + ) + @RequestParam(name = "direction", required = false) direction: OrderDirection?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): Long { return marketUserDataProxy.getTradeHistoryCount( - securityContext.authentication.name, symbol, startTime, endTime, direction + securityContext.authentication.name, + symbol, + startTime, + endTime, + direction ) } @GetMapping("/history/withdraw") + @Operation( + summary = "Get withdraw history", + description = """GET /opex/v1/user/history/withdraw. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` defaults to 10 and `offset` defaults to 0 when omitted. +- `ascendingByTime` controls time sorting when supported. + +Allowed values: +- status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- transferMethod in response: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = WithdrawResponse::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getWithdrawHistory( - @RequestParam currency: String?, - @RequestParam status: WithdrawStatus?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam limit: Int?, - @RequestParam offset: Int?, - @RequestParam ascendingByTime: Boolean?, + @Parameter(name = "currency", description = "Optional currency symbol.", required = false) + @RequestParam(name = "currency", required = false) currency: String?, + @Parameter( + name = "status", + description = "Optional withdraw status. Allowed values: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE.", + required = false + ) + @RequestParam(name = "status", required = false) status: WithdrawStatus?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(name = "limit", description = "Optional page size. Defaults to 10 when omitted.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(name = "offset", description = "Optional page offset. Defaults to 0 when omitted.", required = false) + @RequestParam(name = "offset", required = false) offset: Int?, + @Parameter( + name = "ascendingByTime", + description = "Optional sorting flag. true sorts ascending by time; false sorts descending where supported.", + required = false + ) + @RequestParam(name = "ascendingByTime", required = false) ascendingByTime: Boolean?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return walletProxy.getWithdrawTransactions( @@ -113,11 +368,49 @@ class UserHistoryController( } @GetMapping("/history/withdraw/count") + @Operation( + summary = "Count withdraw history", + description = """GET /opex/v1/user/history/withdraw/count. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. + +Allowed values: +- status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = Long::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getWithdrawHistoryCount( - @RequestParam currency: String?, - @RequestParam status: WithdrawStatus?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, + @Parameter(name = "currency", description = "Optional currency symbol.", required = false) + @RequestParam(name = "currency", required = false) currency: String?, + @Parameter( + name = "status", + description = "Optional withdraw status. Allowed values: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE.", + required = false + ) + @RequestParam(name = "status", required = false) status: WithdrawStatus?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): Long { return walletProxy.getWithdrawTransactionsCount( @@ -131,13 +424,62 @@ class UserHistoryController( } @GetMapping("/history/deposit") + @Operation( + summary = "Get deposit history", + description = """GET /opex/v1/user/history/deposit. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` defaults to 10 and `offset` defaults to 0 when omitted. +- `ascendingByTime` controls time sorting when supported. + +Allowed values in response: +- status: PROCESSING, DONE, INVALID. +- type: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = DepositHistoryResponse::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getDepositHistory( - @RequestParam currency: String?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam limit: Int?, - @RequestParam offset: Int?, - @RequestParam ascendingByTime: Boolean?, + @Parameter(name = "currency", description = "Optional currency symbol.", required = false) + @RequestParam(name = "currency", required = false) currency: String?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(name = "limit", description = "Optional page size. Defaults to 10 when omitted.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(name = "offset", description = "Optional page offset. Defaults to 0 when omitted.", required = false) + @RequestParam(name = "offset", required = false) offset: Int?, + @Parameter( + name = "ascendingByTime", + description = "Optional sorting flag. true sorts ascending by time; false sorts descending where supported.", + required = false + ) + @RequestParam(name = "ascendingByTime", required = false) ascendingByTime: Boolean?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return walletProxy.getDepositTransactions( @@ -153,10 +495,40 @@ class UserHistoryController( } @GetMapping("/history/deposit/count") + @Operation( + summary = "Count deposit history", + description = """GET /opex/v1/user/history/deposit/count. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = Long::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getDepositHistoryCount( - @RequestParam currency: String?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, + @Parameter(name = "currency", description = "Optional currency symbol.", required = false) + @RequestParam(name = "currency", required = false) currency: String?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): Long { return walletProxy.getDepositTransactionsCount( @@ -169,14 +541,66 @@ class UserHistoryController( } @GetMapping("/history/transaction") + @Operation( + summary = "Get transaction history", + description = """GET /opex/v1/user/history/transaction. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` defaults to 10 and `offset` defaults to 0 when omitted. +- `ascendingByTime` controls time sorting when supported. + +Allowed values: +- category: TRADE, DEPOSIT, DEPOSIT_TO, WITHDRAW_FROM, WITHDRAW, FEE, SWAP, REFERRAL_COMMISSION, REFERRAL_KYC_REWARD, REFERENT_COMMISSION, KYC_ACCEPTED_REWARD, SYSTEM.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = UserTransactionHistory::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTransactionHistory( - @RequestParam currency: String?, - @RequestParam category: UserTransactionCategory?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam limit: Int?, - @RequestParam offset: Int?, - @RequestParam ascendingByTime: Boolean?, + @Parameter(name = "currency", description = "Optional currency symbol.", required = false) + @RequestParam(name = "currency", required = false) currency: String?, + @Parameter( + name = "category", + description = "Optional transaction category. Allowed values: TRADE, DEPOSIT, DEPOSIT_TO, WITHDRAW_FROM, WITHDRAW, FEE, SWAP, REFERRAL_COMMISSION, REFERRAL_KYC_REWARD, REFERENT_COMMISSION, KYC_ACCEPTED_REWARD, SYSTEM.", + required = false + ) + @RequestParam(name = "category", required = false) category: UserTransactionCategory?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(name = "limit", description = "Optional page size. Defaults to 10 when omitted.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(name = "offset", description = "Optional page offset. Defaults to 0 when omitted.", required = false) + @RequestParam(name = "offset", required = false) offset: Int?, + @Parameter( + name = "ascendingByTime", + description = "Optional sorting flag. true sorts ascending by time; false sorts descending where supported.", + required = false + ) + @RequestParam(name = "ascendingByTime", required = false) ascendingByTime: Boolean?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return walletProxy.getTransactions( @@ -193,11 +617,49 @@ class UserHistoryController( } @GetMapping("/history/transaction/count") + @Operation( + summary = "Count transaction history", + description = """GET /opex/v1/user/history/transaction/count. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. + +Allowed values: +- category: TRADE, DEPOSIT, DEPOSIT_TO, WITHDRAW_FROM, WITHDRAW, FEE, SWAP, REFERRAL_COMMISSION, REFERRAL_KYC_REWARD, REFERENT_COMMISSION, KYC_ACCEPTED_REWARD, SYSTEM.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = Long::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTransactionHistoryCount( - @RequestParam currency: String?, - @RequestParam category: UserTransactionCategory?, - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, + @Parameter(name = "currency", description = "Optional currency symbol.", required = false) + @RequestParam(name = "currency", required = false) currency: String?, + @Parameter( + name = "category", + description = "Optional transaction category. Allowed values: TRADE, DEPOSIT, DEPOSIT_TO, WITHDRAW_FROM, WITHDRAW, FEE, SWAP, REFERRAL_COMMISSION, REFERRAL_KYC_REWARD, REFERENT_COMMISSION, KYC_ACCEPTED_REWARD, SYSTEM.", + required = false + ) + @RequestParam(name = "category", required = false) category: UserTransactionCategory?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): Long { return walletProxy.getTransactionsCount( @@ -211,10 +673,46 @@ class UserHistoryController( } @GetMapping("/summary/trade") + @Operation( + summary = "Get trade summary", + description = """GET /opex/v1/user/summary/trade. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` limits the number of summary rows when supported.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = TransactionSummary::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getTradeTransactionSummary( - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam limit: Int?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(name = "limit", description = "Optional number of summary rows.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return walletProxy.getUserTradeTransactionSummary( @@ -227,10 +725,46 @@ class UserHistoryController( } @GetMapping("/summary/deposit") + @Operation( + summary = "Get deposit summary", + description = """GET /opex/v1/user/summary/deposit. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` limits the number of summary rows when supported.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = TransactionSummary::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getDepositSummary( - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam limit: Int?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(name = "limit", description = "Optional number of summary rows.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return walletProxy.getUserDepositSummary( @@ -243,10 +777,46 @@ class UserHistoryController( } @GetMapping("/summary/withdraw") + @Operation( + summary = "Get withdraw summary", + description = """GET /opex/v1/user/summary/withdraw. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` limits the number of summary rows when supported.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = TransactionSummary::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getWithdrawSummary( - @RequestParam startTime: Long?, - @RequestParam endTime: Long?, - @RequestParam limit: Int?, + @Parameter( + name = "startTime", + description = "Optional start timestamp in epoch milliseconds.", + required = false + ) + @RequestParam(name = "startTime", required = false) startTime: Long?, + @Parameter(name = "endTime", description = "Optional end timestamp in epoch milliseconds.", required = false) + @RequestParam(name = "endTime", required = false) endTime: Long?, + @Parameter(name = "limit", description = "Optional number of summary rows.", required = false) + @RequestParam(name = "limit", required = false) limit: Int?, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): List { return walletProxy.getUserWithdrawSummary( @@ -259,18 +829,90 @@ class UserHistoryController( } @PostMapping("/history/swap") + @Operation( + summary = "Get swap history", + description = """POST /opex/v1/user/history/swap. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. +- `limit` defaults to 10 and `offset` defaults to 0 when omitted. + +Allowed values: +- status: Created, Expired, Committed.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = UserSwapTransactionRequest::class) + )] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = SwapResponse::class)) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getSwapHistory( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UserTransactionRequest + @RequestBody request: UserSwapTransactionRequest ): List { return walletProxy.getSwapTransactions(securityContext.jwtAuthentication().tokenValue(), request) } @PostMapping("/history/swap/count") + @Operation( + summary = "Count swap history", + description = """POST /opex/v1/user/history/swap/count. +Security: Bearer user-token required. Requires authenticated user JWT. + +Behavior / Validation: +- Optional filters are applied only when provided. +- `startTime` and `endTime` are epoch milliseconds. + +Allowed values: +- status: Created, Expired, Committed.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = UserSwapTransactionRequest::class) + )] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = Long::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getSwapHistoryCount( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UserTransactionRequest + @RequestBody request: UserSwapTransactionRequest ): Long { return walletProxy.getSwapTransactionsCount(securityContext.jwtAuthentication().tokenValue(), request) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserLevelAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserLevelAdminController.kt new file mode 100644 index 000000000..79a8dfffe --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserLevelAdminController.kt @@ -0,0 +1,88 @@ +package co.nilin.opex.api.ports.opex.controller; + +import co.nilin.opex.api.core.inout.UserLevelConfig +import co.nilin.opex.api.core.spi.ConfigProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* +@RestController +@RequestMapping("/opex/v1") +@Tag( + name = "Admin User Config", + description = "User level configuration operations." +) +public class UserLevelAdminController(private val configProxy: ConfigProxy) { + @PutMapping("/admin/user-level/config") + @Operation( + summary = "Update user level config", + description = """PUT /opex/v1/admin/user-level/config. +Security: Bearer admin-token required. Required authority: ROLE_admin.""", + security = [SecurityRequirement(name = "bearerAuth")], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = UserLevelConfig::class) + )] + ), + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = UserLevelConfig::class) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. No response body.", content = [Content()]) + ] + ) + suspend fun updateUserLevelConfig( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody userLevelConfig: UserLevelConfig + ): UserLevelConfig { + return configProxy.updateUserLevelConfig(securityContext.jwtAuthentication().tokenValue(), userLevelConfig) + } + + @DeleteMapping("/admin/user-level/config/{userLevel}/{language}") + @Operation( + summary = "Delete user level config", + description = """DELETE /opex/v1/admin/user-level/config/{userLevel}/{language}. +Security: Bearer admin-token required. Required authority: ROLE_admin. + +Allowed values: +- language: EN, FA, AR, UZ.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. No response body.", content = [Content()]) + ] + ) + suspend fun deleteUserLevelConfig( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "userLevel", description = "User level key.", required = true) + @PathVariable userLevel: String, + @Parameter(name = "language", description = "Language: EN, FA, AR, UZ.", required = true) + @PathVariable language: String + ) { + configProxy.deleteUserLevelConfig(securityContext.jwtAuthentication().tokenValue(), userLevel, language) + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserLevelController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserLevelController.kt new file mode 100644 index 000000000..92f21c3fe --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserLevelController.kt @@ -0,0 +1,45 @@ +package co.nilin.opex.api.ports.opex.controller; + +import co.nilin.opex.api.core.inout.UserLevelConfig +import co.nilin.opex.api.core.spi.ConfigProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* +@RestController +@RequestMapping("/opex/v1") +@Tag( + name = "Use Level", + description = "Fetch user level config, public endpoint" +) +public class UserLevelController(private val configProxy: ConfigProxy) { + @GetMapping("/user-level/config") + @Operation( + summary = "Get user level config", + description = """GET /opex/v1/user-level/config. +Security: Public endpoint. No Bearer token is required.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = UserLevelConfig::class)) + )] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. No response body.", content = [Content()]) + ] + ) + suspend fun getUserLevelConfig(): List { + return configProxy.getUserLevelConfig() + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/VoucherController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/VoucherController.kt index 50c7800ec..f0e6ec518 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/VoucherController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/VoucherController.kt @@ -3,24 +3,46 @@ package co.nilin.opex.api.ports.opex.controller import co.nilin.opex.api.core.inout.SubmitVoucherResponse import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.common.security.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag @RestController @RequestMapping("/opex/v1/voucher") +@Tag(name = "Voucher", description = "Authenticated voucher submission operations.") class VoucherController(private val walletProxy: WalletProxy) { @PutMapping("/{code}") + @Operation( + summary = "Submit voucher", + description = """PUT /opex/v1/voucher/{code}. +Security: Bearer user-token required. Required authority: PERM_voucher:submit.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = SubmitVoucherResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: PERM_voucher:submit. No response body.", content = [Content()]) + ] + ) suspend fun submitVoucher( + @Parameter(name = "code", description = "Voucher code.", required = true) @PathVariable code: String, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): SubmitVoucherResponse { return walletProxy.submitVoucher(code, securityContext.jwtAuthentication().tokenValue()) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletAdminController.kt index a0ab52916..cb5412c45 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletAdminController.kt @@ -11,20 +11,49 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag @RestController @RequestMapping("/opex/v1/admin/wallet") +@Tag(name = "Wallet Admin", description = "Admin wallet overview and total balance operations.") class WalletAdminController( private val walletProxy: WalletProxy ) { @GetMapping("/users") + @Operation( + summary = "Get users wallets", + description = """GET /opex/v1/admin/wallet/users. +Behavior: Optional filters include uuid, currency, excludeSystem, limit, and offset. `excludeSystem` defaults to false. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- excludeSystem: true, false.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", array = ArraySchema(schema = Schema(implementation = WalletDataResponse::class)))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun getUsersWallets( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "uuid", description = "User/profile/terminal UUID depending on the endpoint context.", required = false) @RequestParam(required = false) uuid: String?, + @Parameter(name = "currency", description = "Currency symbol, e.g. USDT.", required = false) @RequestParam(required = false) currency: String?, + @Parameter(name = "excludeSystem", description = "Whether system wallets should be excluded from the result.", required = false) @RequestParam(required = false, defaultValue = "false") excludeSystem: Boolean, + @Parameter(name = "limit", description = "Optional page size.", required = false) @RequestParam limit: Int?, + @Parameter(name = "offset", description = "Optional page offset.", required = false) @RequestParam offset: Int? ): List { return walletProxy.getUsersWallets( @@ -38,12 +67,40 @@ class WalletAdminController( } @GetMapping("/system/total") - suspend fun getSystemWalletsTotal(@CurrentSecurityContext securityContext: SecurityContext): List { + @Operation( + summary = "Get system wallets total", + description = """GET /opex/v1/admin/wallet/system/total. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- excludeSystem: true, false.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", array = ArraySchema(schema = Schema(implementation = WalletTotal::class)))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) + suspend fun getSystemWalletsTotal(@Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext): List { return walletProxy.getSystemWalletsTotal(securityContext.jwtAuthentication().tokenValue()) } @GetMapping("/users/total") - suspend fun getUsersWalletsTotal(@CurrentSecurityContext securityContext: SecurityContext): List? { + @Operation( + summary = "Get users wallets total", + description = """GET /opex/v1/admin/wallet/users/total. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- excludeSystem: true, false.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", array = ArraySchema(schema = Schema(implementation = WalletTotal::class)))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) + suspend fun getUsersWalletsTotal(@Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext): List? { return walletProxy.getUsersWalletsTotal(securityContext.jwtAuthentication().tokenValue()) } } diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletController.kt index effbdc39d..f5ccedebd 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletController.kt @@ -15,18 +15,42 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag @RestController("walletOpexController") @RequestMapping("/opex/v1/wallet") +@Tag(name = "Wallet", description = "Authenticated user wallet asset, limits, and deposit address operations.") class WalletController( private val walletProxy: WalletProxy, - private val bcGatewayProxy: BlockchainGatewayProxy, + private val bcGatewayProxy: BlockchainGatewayProxy ) { @GetMapping("/asset") + @Operation( + summary = "Get user assets", + description = """GET /opex/v1/wallet/asset. +Behavior: If `symbol` is omitted, all user wallet assets are returned. If provided, only that currency asset is returned. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- walletType values in related wallet responses/requests: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", array = ArraySchema(schema = Schema(implementation = AssetResponse::class)))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]) + ] + ) suspend fun getUserAssets( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @RequestParam(required = false) symbol: String?, + @Parameter(name = "symbol", description = "Trading pair or currency symbol, depending on endpoint.", required = false) + @RequestParam(required = false) symbol: String? ): List { val auth = securityContext.jwtAuthentication() val result = arrayListOf() @@ -44,17 +68,46 @@ class WalletController( } @GetMapping("/limits") - suspend fun getWalletOwnerLimits(@CurrentSecurityContext securityContext: SecurityContext): OwnerLimitsResponse { + @Operation( + summary = "Get wallet owner limits", + description = """GET /opex/v1/wallet/limits. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- walletType values in related wallet responses/requests: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = OwnerLimitsResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]) + ] + ) + suspend fun getWalletOwnerLimits(@Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext): OwnerLimitsResponse { return walletProxy.getOwnerLimits( securityContext.jwtAuthentication().name, - securityContext.jwtAuthentication().tokenValue(), + securityContext.jwtAuthentication().tokenValue() ) } @GetMapping("/deposit/address") + @Operation( + summary = "Assign address", + description = """GET /opex/v1/wallet/deposit/address. +Source of values: `gatewayUuid` should come from server-provided gateway data for the selected currency/network. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- walletType values in related wallet responses/requests: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = AssignAddressResponse::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]) + ] + ) suspend fun assignAddress( + @Parameter(name = "currency", description = "Currency symbol, e.g. USDT.", required = true) @RequestParam currency: String, + @Parameter(name = "gatewayUuid", description = "Gateway UUID.", required = true) @RequestParam gatewayUuid: String, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): AssignAddressResponse { diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawAdminController.kt index 8fb440bee..8a902b4e8 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawAdminController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawAdminController.kt @@ -8,18 +8,47 @@ import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* import java.math.BigDecimal +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag @RestController @RequestMapping("/opex/v1/admin/withdraw") +@Tag(name = "Withdraw Admin", description = "Admin manual withdraw and withdraw workflow operations.") class WithdrawAdminController( - private val walletProxy: WalletProxy, + private val walletProxy: WalletProxy ) { @PostMapping("/manually/{amount}_{symbol}/{sourceUuid}") + @Operation( + summary = "Withdraw manually", + description = """POST /opex/v1/admin/withdraw/manually/{amount}_{symbol}/{sourceUuid}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD. +- sourceWalletType where used: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = TransferResult::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun withdrawManually( + @Parameter(name = "symbol", description = "Trading pair or currency symbol, depending on endpoint.", required = true) @PathVariable("symbol") symbol: String, + @Parameter(name = "sourceUuid", description = "Source wallet owner UUID.", required = true) @PathVariable("sourceUuid") sourceUuid: String, + @Parameter(name = "amount", description = "Withdraw amount.", required = true) @PathVariable("amount") amount: BigDecimal, @RequestBody request: ManualTransferRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext ): TransferResult { return walletProxy.withdrawManually( @@ -32,28 +61,83 @@ class WithdrawAdminController( } @PostMapping("/{withdrawUuid}/accept") + @Operation( + summary = "Start the process of the withdraw", + description = """POST /opex/v1/admin/withdraw/{withdrawUuid}/accept. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD. +- sourceWalletType where used: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = WithdrawActionResult::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun acceptWithdraw( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, - @PathVariable withdrawUuid: String, + @Parameter(name = "withdrawUuid", description = "Withdraw UUID.", required = true) + @PathVariable withdrawUuid: String ): WithdrawActionResult { return walletProxy.acceptWithdraw(securityContext.jwtAuthentication().tokenValue(), withdrawUuid) } @PostMapping("/{withdrawUuid}/done") + @Operation( + summary = "Done withdraw", + description = """POST /opex/v1/admin/withdraw/{withdrawUuid}/done. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD. +- sourceWalletType where used: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = WithdrawActionResult::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun doneWithdraw( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "withdrawUuid", description = "Withdraw UUID.", required = true) @PathVariable withdrawUuid: String, - @RequestBody request: WithdrawDoneRequest, + @RequestBody request: WithdrawDoneRequest ): WithdrawActionResult { return walletProxy.doneWithdraw(securityContext.jwtAuthentication().tokenValue(), withdrawUuid, request) } @PostMapping("/{withdrawUuid}/reject") + @Operation( + summary = "Reject withdraw", + description = """POST /opex/v1/admin/withdraw/{withdrawUuid}/reject. +Validation: Request body contains the rejection reason/details required by schema. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Allowed values: +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: CARD_TO_CARD, SHEBA, ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD. +- sourceWalletType where used: MAIN, EXCHANGE, CASHOUT.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "Successful response.", content = [Content(mediaType = "application/json", schema = Schema(implementation = WithdrawActionResult::class))]), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]), + ApiResponse(responseCode = "403", description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", content = [Content()]) + ] + ) suspend fun rejectWithdraw( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "withdrawUuid", description = "Withdraw UUID.", required = true) @PathVariable withdrawUuid: String, - @RequestBody request: WithdrawRejectRequest, + @RequestBody request: WithdrawRejectRequest ): WithdrawActionResult { return walletProxy.rejectWithdraw(securityContext.jwtAuthentication().tokenValue(), withdrawUuid, request) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawController.kt index 5cde9598a..42a94b3c4 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawController.kt @@ -4,18 +4,60 @@ import co.nilin.opex.api.core.inout.* import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/opex/v1/withdraw") +@Tag(name = "Withdraw", description = "Authenticated user withdraw and OTP operations.") class WithdrawController( - private val walletProxy: WalletProxy, + private val walletProxy: WalletProxy ) { @PostMapping + @Operation( + summary = "Request withdraw", + description = """POST /opex/v1/withdraw. +Security: Bearer user-token required. Required authority: PERM_withdraw:write. + +Behavior: The response may require an OTP next action before the withdraw can continue. +Allowed values: +- otpType: SMS, EMAIL. +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = WithdrawActionResult::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: PERM_withdraw:write. No response body.", + content = [Content()] + ) + ] + ) suspend fun requestWithdraw( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody request: RequestWithdrawBody ): WithdrawActionResult? { @@ -26,8 +68,34 @@ class WithdrawController( } @PutMapping("/{withdrawUuid}/cancel") + @Operation( + summary = "Cancel withdraw", + description = """PUT /opex/v1/withdraw/{withdrawUuid}/cancel. +Security: Bearer user-token required. Required authority: PERM_withdraw:write. +Allowed values: +- otpType: SMS, EMAIL. +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse(responseCode = "200", description = "No response body.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: PERM_withdraw:write. No response body.", + content = [Content()] + ) + ] + ) suspend fun cancelWithdraw( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "withdrawUuid", description = "Withdraw UUID.", required = true) @PathVariable withdrawUuid: String ) { walletProxy.cancelWithdraw( @@ -37,8 +105,36 @@ class WithdrawController( } @GetMapping("/{withdrawUuid}") + @Operation( + summary = "Find withdraw", + description = """GET /opex/v1/withdraw/{withdrawUuid}. +Security: Bearer user-token required. Requires authenticated user JWT. +Allowed values: +- otpType: SMS, EMAIL. +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = WithdrawResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun findWithdraw( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "withdrawUuid", description = "Withdraw UUID.", required = true) @PathVariable withdrawUuid: String ): WithdrawResponse { return walletProxy.findWithdraw( @@ -48,20 +144,95 @@ class WithdrawController( } @PostMapping("/{withdrawUuid}/otp/{otpType}/request") + @Operation( + summary = "Request otp", + description = """POST /opex/v1/withdraw/{withdrawUuid}/otp/{otpType}/request. +Validation: `otpType` allowed values: SMS, EMAIL. +Security: Bearer user-token required. Required authority: PERM_withdraw:write. + +Validation: `otpType` must be a supported OTP delivery type such as EMAIL or SMS. +Allowed values: +- otpType: SMS, EMAIL. +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TempOtpResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: PERM_withdraw:write. No response body.", + content = [Content()] + ) + ] + ) suspend fun requestOTP( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "withdrawUuid", description = "Withdraw UUID.", required = true) @PathVariable withdrawUuid: String, + @Parameter(name = "otpType", description = "OTP delivery type. Allowed values: SMS, EMAIL.", required = true) @PathVariable otpType: OTPType ): TempOtpResponse { return walletProxy.requestWithdrawOTP(securityContext.jwtAuthentication().tokenValue(), withdrawUuid, otpType) } @PostMapping("/{withdrawUuid}/otp/{otpType}/verify") + @Operation( + summary = "Verify otp", + description = """POST /opex/v1/withdraw/{withdrawUuid}/otp/{otpType}/verify. +Validation: `otpType` allowed values: SMS, EMAIL. `otpCode` is required. +Security: Bearer user-token required. Required authority: PERM_withdraw:write. + +Validation: `otpType` must be a supported OTP delivery type such as EMAIL or SMS. `otpCode` is required for verification. +Allowed values: +- otpType: SMS, EMAIL. +- withdraw status: REQUESTED, CREATED, ACCEPTED, CANCELED, REJECTED, DONE. +- withdrawType: ON_CHAIN, OFF_CHAIN. +- transferMethod: CARD, SHEBA, IPG, EXCHANGE, MANUALLY, VOUCHER, MPG, REWARD.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = WithdrawActionResult::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: PERM_withdraw:write. No response body.", + content = [Content()] + ) + ] + ) suspend fun verifyOTP( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @Parameter(name = "withdrawUuid", description = "Withdraw UUID.", required = true) @PathVariable withdrawUuid: String, + @Parameter(name = "otpType", description = "OTP delivery type. Allowed values: SMS, EMAIL.", required = true) @PathVariable otpType: OTPType, - @RequestParam otpCode: String, + @Parameter(name = "otpCode", description = "OTP code received by user.", required = true) + @RequestParam otpCode: String ): WithdrawActionResult { return walletProxy.verifyWithdrawOTP( securityContext.jwtAuthentication().tokenValue(), @@ -70,4 +241,4 @@ class WithdrawController( otpCode ) } -} \ No newline at end of file +} diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt index 68dd031c8..08c07f63e 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt @@ -131,7 +131,7 @@ class ProfileProxyImpl(@Qualifier("generalWebClient") private val webClient: Web override suspend fun getAllAddressBooks(token: String): List { return webClient.get() - .uri("$baseUrl/address-book`") + .uri("$baseUrl/address-book") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() @@ -188,7 +188,7 @@ class ProfileProxyImpl(@Qualifier("generalWebClient") private val webClient: Web override suspend fun getBankAccounts(token: String): List { return webClient.get() - .uri("$baseUrl/bank-account`") + .uri("$baseUrl/bank-account") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt index 25d7b969f..a16a07d5e 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt @@ -207,7 +207,6 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC limit, offset, ascendingByTime == true, - null ) ) ) @@ -356,26 +355,6 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC } } - override suspend fun deposit( - request: RequestDepositBody - ): TransferResult? { - return withContext(ProxyDispatchers.wallet) { - webClient.post() - .uri("$baseUrl/deposit/${request.amount}_${request.chain}_${request.symbol}/${request.receiverUuid}_${request.receiverWalletType}") { - it.apply { - request.description?.let { description -> queryParam("description", description) } - request.transferRef?.let { transferRef -> queryParam("transferRef", transferRef) } - request.gatewayUuid?.let { gatewayUuid -> queryParam("gatewayUuid", gatewayUuid) } - }.build() - }.accept(MediaType.APPLICATION_JSON) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .retrieve() - .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToMono() - .awaitFirstOrNull() - } - } - override suspend fun requestWithdraw( token: String, request: RequestWithdrawBody @@ -442,7 +421,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC } } - override suspend fun getSwapTransactions(token: String, request: UserTransactionRequest): List { + override suspend fun getSwapTransactions(token: String, request: UserSwapTransactionRequest): List { return webClient.post() .uri("$baseUrl/v1/swap/history") .accept(MediaType.APPLICATION_JSON) @@ -457,7 +436,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC override suspend fun getSwapTransactionsCount( token: String, - request: UserTransactionRequest + request: UserSwapTransactionRequest ): Long { return webClient.post() .uri("$baseUrl/v1/swap/history/count") @@ -535,7 +514,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC override suspend fun getSwapTransactionsForAdmin( token: String, - request: UserTransactionRequest + request: UserSwapTransactionRequest ): List { return webClient.post() .uri("$baseUrl/admin/v1/swap/history") @@ -850,7 +829,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC terminalUuid: String ): TerminalLocalizationResponse { return webClient.get() - .uri("$baseUrl/admin/deposit/terminal/${terminalUuid}/localization") + .uri("$baseUrl/admin/terminal/${terminalUuid}/localization") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() @@ -865,7 +844,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC terminalLocalizations: List ): TerminalLocalizationResponse { return webClient.post() - .uri("$baseUrl/admin/deposit/terminal/${terminalUuid}/localization") + .uri("$baseUrl/admin/terminal/${terminalUuid}/localization") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .body(Mono.just(terminalLocalizations)) @@ -877,7 +856,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC override suspend fun deleteTerminalLocalization(token: String, id: Long) { webClient.delete() - .uri("$baseUrl/admin/deposit/terminal/localization/${id}") + .uri("$baseUrl/admin/terminal/localization/${id}") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() @@ -934,7 +913,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC terminal: TerminalCommand ): TerminalCommand? { return webClient.post() - .uri("$baseUrl/admin/deposit/terminal") + .uri("$baseUrl/admin/terminal") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .body(Mono.just(terminal)) @@ -947,10 +926,10 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC override suspend fun updateTerminal( token: String, terminalUuid: String, - terminal: TerminalCommand + terminal: TerminalUpdateCommand ): TerminalCommand? { return webClient.put() - .uri("$baseUrl/admin/deposit/terminal/${terminalUuid}") + .uri("$baseUrl/admin/terminal/${terminalUuid}") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .body(Mono.just(terminal)) @@ -962,7 +941,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC override suspend fun deleteTerminal(token: String, terminalUuid: String) { webClient.delete() - .uri("$baseUrl/admin/deposit/terminal/${terminalUuid}") + .uri("$baseUrl/admin/terminal/${terminalUuid}") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() @@ -989,13 +968,13 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC terminalUuid: String ): TerminalCommand? { return webClient.get() - .uri("$baseUrl/admin/deposit/terminal/${terminalUuid}") + .uri("$baseUrl/admin/terminal/${terminalUuid}") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) .bodyToMono() - .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + .awaitFirstOrElse { throw OpexError.NotFound.exception() } } override suspend fun getAssignedGatewayToTerminal( @@ -1003,7 +982,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC terminalUuid: String ): List? { return webClient.get() - .uri("$baseUrl/admin/deposit/terminal/${terminalUuid}/gateway") + .uri("$baseUrl/admin/terminal/${terminalUuid}/gateway") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() @@ -1047,7 +1026,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC .awaitBodilessEntity() } - override suspend fun addCurrencyToGateway( + override suspend fun addGatewayToCurrency( token: String, currencySymbol: String, gatewayCommand: CurrencyGatewayCommand @@ -1067,7 +1046,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC token: String, gatewayUuid: String, currencySymbol: String, - gatewayCommand: CurrencyGatewayCommand + gatewayCommand: CurrencyGatewayUpdateCommand ): CurrencyGatewayCommand? { return webClient.put() .uri("$baseUrl/currency/${currencySymbol}/gateway/${gatewayUuid}") diff --git a/auth-gateway/auth-gateway-app/pom.xml b/auth-gateway/auth-gateway-app/pom.xml index 0ff3a5ade..1be88098a 100644 --- a/auth-gateway/auth-gateway-app/pom.xml +++ b/auth-gateway/auth-gateway-app/pom.xml @@ -129,6 +129,11 @@ micrometer-registry-prometheus runtime + + org.springdoc + springdoc-openapi-starter-webflux-api + 2.6.0 + diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OpenApiConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OpenApiConfig.kt new file mode 100644 index 000000000..f736645e7 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OpenApiConfig.kt @@ -0,0 +1,36 @@ +package co.nilin.opex.auth.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration(proxyBeanMethods = false) +class AuthGatewayOpenApiConfig() { + + @Bean + fun authGatewayOpenApi(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("Opex Auth Gateway API") + .description("OpenAPI documentation for Opex Auth Gateway APIs.") + .version("1.0.1-beta.7") + .description("Backend for opex exchange.") + ) + .components( + Components().addSecuritySchemes( + "bearerAuth", + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT Bearer token") + ) + ) + } +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OpenApiCorsConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OpenApiCorsConfig.kt new file mode 100644 index 000000000..5c31a0c2c --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OpenApiCorsConfig.kt @@ -0,0 +1,46 @@ +package co.nilin.opex.auth.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.reactive.CorsWebFilter +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource + +@Configuration(proxyBeanMethods = false) +class OpenApiCorsConfig( + @Value("\${app.swagger.cors.enabled:false}") + private val enabled: Boolean, + + @Value("\${app.swagger.cors.allowed-origins:http://localhost:8110}") + private val allowedOrigins: String +) { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + fun swaggerCorsWebFilter(): CorsWebFilter { + val config = CorsConfiguration().apply { + allowedOrigins = if (enabled) { + this@OpenApiCorsConfig.allowedOrigins + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + } else { + emptyList() + } + + allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + allowedHeaders = listOf("*") + exposedHeaders = listOf("Location", "Content-Disposition") + allowCredentials = false + maxAge = 3600 + } + + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", config) + + return CorsWebFilter(source) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt index 5b2a71194..3911ec4ff 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt @@ -1,13 +1,13 @@ package co.nilin.opex.auth.config import co.nilin.opex.auth.utils.AudienceValidator -import jakarta.enterprise.inject.Default import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary import org.springframework.core.annotation.Order -import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.http.HttpMethod import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity @@ -15,7 +15,6 @@ import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers import org.springframework.web.reactive.function.client.WebClient @@ -23,21 +22,36 @@ import org.springframework.web.reactive.function.client.WebClient @EnableWebFluxSecurity @Configuration class SecurityConfig( - @Qualifier("keycloakWebClient") - private val webClient: WebClient, - private val keycloakConfig: KeycloakConfig + @Qualifier("keycloakWebClient") private val webClient: WebClient, + private val keycloakConfig: KeycloakConfig, ) { + @Value("\${swagger.auth.enabled:false}") + private var swaggerAuthEnabled: Boolean = false + + @Value("\${swagger.auth.authority:ROLE_admin}") + private lateinit var swaggerAuthority: String + @Bean - @Order(2) - fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http.csrf { it.disable() } - .authorizeExchange {it.pathMatchers("/actuator/**").permitAll() - .pathMatchers("/v1/oauth/protocol/openid-connect/**").permitAll() - .pathMatchers("/v1/oauth.***").permitAll() - .pathMatchers("/v1/user/public/**").permitAll() - .pathMatchers("/v1/user/update/**").permitAll() - .anyExchange().authenticated() + @Order(0) + fun swaggerSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + val swaggerPaths = arrayOf( + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs", + "/v3/api-docs/**", + "/webjars/**" + ) + + return http + .securityMatcher(ServerWebExchangeMatchers.pathMatchers(*swaggerPaths)) + .csrf { it.disable() } + .authorizeExchange { + if (swaggerAuthEnabled) { + it.anyExchange().hasAuthority(swaggerAuthority) + } else { + it.anyExchange().permitAll() + } } .oauth2ResourceServer { it.jwt(Customizer.withDefaults()) } .build() @@ -52,19 +66,30 @@ class SecurityConfig( "/v1/oauth/protocol/openid-connect/token/resend-otp" ) ) + .csrf { it.disable() } + .authorizeExchange { it.anyExchange().authenticated() } + .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtDecoder(preAuthJwtDecoder()) } } + .build() + } + + @Bean + @Order(2) + fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http .csrf { it.disable() } .authorizeExchange { - it.anyExchange().authenticated() - } - .oauth2ResourceServer { it -> - it.jwt { - it.jwtDecoder(preAuthJwtDecoder()) - } + it.pathMatchers("/actuator/**").permitAll() + .pathMatchers("/v1/oauth/protocol/openid-connect/**").permitAll() + .pathMatchers("/v1/oauth.***").permitAll() + .pathMatchers("/v1/user/public/**").permitAll() + .pathMatchers("/v1/user/update/**").permitAll() + .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .anyExchange().authenticated() } + .oauth2ResourceServer { it.jwt(Customizer.withDefaults()) } .build() } - @Bean @Throws(Exception::class) @Primary @@ -90,7 +115,6 @@ class SecurityConfig( return decoder } - @Bean("preAuthJwtDecoder") @Throws(Exception::class) fun preAuthJwtDecoder(): ReactiveJwtDecoder? { @@ -112,5 +136,3 @@ class SecurityConfig( return decoder } } - - diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt index 04d5e12c3..5270d40d4 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt @@ -1,7 +1,20 @@ -package co.nilin.opex.auth.controller; +package co.nilin.opex.auth.controller -import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.model.ConfirmPasswordFlowTokenRequest +import co.nilin.opex.auth.model.ExternalIdpTokenRequest +import co.nilin.opex.auth.model.PasswordFlowTokenRequest +import co.nilin.opex.auth.model.RefreshTokenRequest +import co.nilin.opex.auth.model.ResendOtpRequest +import co.nilin.opex.auth.model.ResendOtpResponse +import co.nilin.opex.auth.model.TokenResponse import co.nilin.opex.auth.service.LoginService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext @@ -12,23 +25,76 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v1/oauth/protocol/openid-connect/") +@Tag( + name = "Auth Gateway - Token", + description = "Token, OTP confirmation, external IdP token, and refresh-token operations." +) class AuthController(private val loginService: LoginService) { @PostMapping("/token") + @Operation( + summary = "Request token", + description = """POST /v1/oauth/protocol/openid-connect/token. +Security: Public endpoint. No Bearer token is required. + +Behavior: Starts password-flow login. If OTP is required, the response contains OTP metadata instead of a final access token. +Allowed values: +- captchaType: INTERNAL, ARCAPTCHA, HCAPTCHA.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = TokenResponse::class))] + ) + ] + ) suspend fun requestGetToken(@RequestBody tokenRequest: PasswordFlowTokenRequest): ResponseEntity { val tokenResponse = loginService.requestGetToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } @PostMapping("/token/confirm") + @Operation( + summary = "Confirm token request", + description = """POST /v1/oauth/protocol/openid-connect/token/confirm. +Security: Public endpoint. No Bearer token is required. + +Validation: `otp` and the pre-auth `token` returned by the token request flow are required. +Behavior: Completes password-flow login after OTP verification.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = TokenResponse::class))] + ) + ] + ) suspend fun confirmGetToken(@RequestBody tokenRequest: ConfirmPasswordFlowTokenRequest): ResponseEntity { val tokenResponse = loginService.confirmGetToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } @PostMapping("/token/resend-otp") + @Operation( + summary = "Resend login OTP", + description = """POST /v1/oauth/protocol/openid-connect/token/resend-otp. +Security: Bearer pre-auth token required. + +Behavior: Resends the OTP for an in-progress login flow. +Source of values: Use the pre-auth token returned by the token request flow.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = ResendOtpResponse::class))] + ), + ApiResponse(responseCode = "401", description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", content = [Content()]) + ] + ) suspend fun resendOtp( @RequestBody resendOtpRequest: ResendOtpRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): ResponseEntity { val response = loginService.resendLoginOtp(resendOtpRequest, securityContext.authentication.name) @@ -36,12 +102,41 @@ class AuthController(private val loginService: LoginService) { } @PostMapping("/token-external") + @Operation( + summary = "Request token by external IdP", + description = """POST /v1/oauth/protocol/openid-connect/token-external. +Security: Public endpoint. No Bearer token is required. + +Behavior: Exchanges an external identity-provider token for an Opex token. OTP verification data may be required depending on the account state.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = TokenResponse::class))] + ) + ] + ) suspend fun getToken(@RequestBody tokenRequest: ExternalIdpTokenRequest): ResponseEntity { val tokenResponse = loginService.getToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } @PostMapping("/refresh") + @Operation( + summary = "Refresh token", + description = """POST /v1/oauth/protocol/openid-connect/refresh. +Security: Public endpoint. No Bearer token is required. + +Validation: `refreshToken` and `clientId` are required. +Behavior: Issues a new access token from a valid refresh token.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = TokenResponse::class))] + ) + ] + ) suspend fun refreshToken(@RequestBody tokenRequest: RefreshTokenRequest): ResponseEntity { val tokenResponse = loginService.refreshToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt index c63d8dfde..7b48faeaa 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt @@ -3,6 +3,11 @@ package co.nilin.opex.auth.controller import co.nilin.opex.auth.model.* import co.nilin.opex.auth.service.ForgetPasswordService import co.nilin.opex.auth.service.RegisterService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -12,53 +17,171 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v1/user/public") +@Tag( + name = "Auth Gateway - Public User", + description = "Public registration and password-recovery operations." +) class PublicUserController( private val forgetPasswordService: ForgetPasswordService, private val registerService: RegisterService ) { - //TODO IMPORTANT: remove in production @PostMapping("/register") + @Operation( + summary = "Register user", + description = """POST /v1/user/public/register. +Security: Public endpoint. No Bearer token is required. + +Behavior: Starts the registration flow and sends OTP if required. +Allowed values: +- captchaType: INTERNAL, ARCAPTCHA, HCAPTCHA.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "object"))] + ) + ] + ) suspend fun registerUser(@Valid @RequestBody request: RegisterUserRequest): ResponseEntity { val otpResponse = registerService.registerUser(request) return ResponseEntity.ok().body(otpResponse) } @PostMapping("/register/verify") + @Operation( + summary = "Verify registration OTP", + description = """POST /v1/user/public/register/verify. +Security: Public endpoint. No Bearer token is required. + +Validation: `username` and `otp` are required. +Behavior: Returns an action token used to confirm registration.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = OTPActionTokenResponse::class) + )] + ) + ] + ) suspend fun verifyRegister(@RequestBody request: VerifyOTPRequest): ResponseEntity { val token = registerService.verifyRegister(request) return ResponseEntity.ok(OTPActionTokenResponse(token)) } @PostMapping("/register/confirm") + @Operation( + summary = "Confirm registration", + description = """POST /v1/user/public/register/confirm. +Security: Public endpoint. No Bearer token is required. + +Validation: `password` and registration action `token` are required. +Behavior: Completes registration and returns login token data.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TokenResponse::class) + )] + ) + ] + ) suspend fun confirmRegister(@RequestBody request: ConfirmRegisterRequest): ResponseEntity { val loginToken = registerService.confirmRegister(request) return ResponseEntity.ok(loginToken) } @PostMapping("/register-external") + @Operation( + summary = "Register external IdP user", + description = """POST /v1/user/public/register-external. +Security: Public endpoint. No Bearer token is required. + +Behavior: Registers a user from an external identity provider token. +Response body: No response body.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ) + ] + ) suspend fun registerExternal(@RequestBody request: ExternalIdpUserRegisterRequest): ResponseEntity { registerService.registerExternalIdpUser(request) return ResponseEntity.ok().build() } - //TODO IMPORTANT: remove in production @PostMapping("/forget") + @Operation( + summary = "Forgot password", + description = """POST /v1/user/public/forget. +Security: Public endpoint. No Bearer token is required. + +Behavior: Starts password-recovery flow and sends OTP if required. +Allowed values: +- captchaType: INTERNAL, ARCAPTCHA, HCAPTCHA.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "object"))] + ) + ] + ) suspend fun forgetPassword(@RequestBody request: ForgotPasswordRequest): ResponseEntity { val otpResponse = forgetPasswordService.forgetPassword(request) return ResponseEntity.ok().body(otpResponse) } @PostMapping("/forget/verify") + @Operation( + summary = "Verify forgot-password OTP", + description = """POST /v1/user/public/forget/verify. +Security: Public endpoint. No Bearer token is required. + +Validation: `username` and `otp` are required. +Behavior: Returns an action token used to confirm password reset.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = OTPActionTokenResponse::class) + )] + ) + ] + ) suspend fun verifyForget(@RequestBody request: VerifyOTPRequest): ResponseEntity { val token = forgetPasswordService.verifyForget(request) return ResponseEntity.ok(OTPActionTokenResponse(token)) } @PostMapping("/forget/confirm") + @Operation( + summary = "Confirm forgot-password flow", + description = """POST /v1/user/public/forget/confirm. +Security: Public endpoint. No Bearer token is required. + +Validation: `newPassword`, `newPasswordConfirmation`, and password-reset action `token` are required. +Response body: No response body.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ) + ] + ) suspend fun forgetPassword(@RequestBody request: ConfirmForgetRequest): ResponseEntity { forgetPasswordService.confirmForget(request) return ResponseEntity.ok().build() } -} \ No newline at end of file +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt index 5fe06d1f3..b2235ace6 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt @@ -7,12 +7,25 @@ import co.nilin.opex.auth.service.LogoutService import co.nilin.opex.auth.service.SessionService import co.nilin.opex.common.OpexError import co.nilin.opex.common.security.jwtAuthentication +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/v1/user") +@Tag( + name = "Auth Gateway - Sessions", + description = "Authenticated user session and logout operations." +) class SessionController( private val forgetPasswordService: ForgetPasswordService, private val logoutService: LogoutService, @@ -20,7 +33,31 @@ class SessionController( ) { @PostMapping("/logout") - suspend fun logout(@CurrentSecurityContext securityContext: SecurityContext) { + @Operation( + summary = "Logout current session", + description = """POST /v1/user/logout. +Security: Bearer user-token required. + +Behavior: Terminates the current session using the `sid` claim from the JWT. +Response body: No response body.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun logout( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ) { val userId = securityContext.jwtAuthentication().name val sid = securityContext.jwtAuthentication().tokenAttributes["sid"] as String? ?: throw OpexError.InvalidToken.exception() @@ -28,7 +65,34 @@ class SessionController( } @PostMapping("/session") + @Operation( + summary = "List user sessions", + description = """POST /v1/user/session. +Security: Bearer user-token required. + +Behavior: Returns user sessions for the authenticated user. `uuid` in the request body is overwritten by the authenticated user id. +Allowed values: +- os: ANDROID, IOS, MOBILE_WEB, DESKTOP_WEB +- status: ACTIVE, EXPIRED, TERMINATED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = Sessions::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) suspend fun getSessions( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, @RequestBody sessionRequest: SessionRequest ): List { @@ -40,13 +104,70 @@ class SessionController( } @DeleteMapping("/session/{sessionId}") - suspend fun logout(@CurrentSecurityContext securityContext: SecurityContext, @PathVariable sessionId: String) { + @Operation( + summary = "Logout one session", + description = """DELETE /v1/user/session/{sessionId}. +Security: Bearer user-token required. + +Behavior: Terminates one user session by id. +Response body: No response body.""", + security = [SecurityRequirement(name = "bearerAuth")], + parameters = [ + Parameter( + name = "sessionId", + `in` = ParameterIn.PATH, + required = true, + description = "Session id to terminate." + ) + ], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun logout( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @PathVariable sessionId: String + ) { val uuid = securityContext.authentication.name logoutService.logoutSession(uuid, sessionId) } @PostMapping("/session/delete-others") - suspend fun logoutOthers(@CurrentSecurityContext securityContext: SecurityContext) { + @Operation( + summary = "Logout other sessions", + description = """POST /v1/user/session/delete-others. +Security: Bearer user-token required. + +Behavior: Terminates all sessions except the current JWT session. +Response body: No response body.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun logoutOthers( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ) { val uuid = securityContext.authentication.name val sid = securityContext.jwtAuthentication().tokenAttributes["sid"] as String? ?: throw OpexError.InvalidToken.exception() @@ -54,10 +175,32 @@ class SessionController( } @PostMapping("/session/delete-all") - suspend fun logoutAll(@CurrentSecurityContext securityContext: SecurityContext) { + @Operation( + summary = "Logout all sessions", + description = """POST /v1/user/session/delete-all. +Security: Bearer user-token required. + +Behavior: Terminates all sessions for the authenticated user. +Response body: No response body.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun logoutAll( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ) { val uuid = securityContext.authentication.name logoutService.logoutAll(uuid) } - } - diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt index 148d26993..3cf756c22 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt @@ -25,7 +25,6 @@ data class OTPVerifyResponse( val type: OTPResultType ) -//TODO IMPORTANT: remove in production data class TempOtpResponse(val otp: String?, val otpReceiver: OTPReceiver?) enum class OTPAction { diff --git a/auth-gateway/auth-gateway-app/src/main/resources/application.yml b/auth-gateway/auth-gateway-app/src/main/resources/application.yml index 31b1ae63b..68dd6b18e 100644 --- a/auth-gateway/auth-gateway-app/src/main/resources/application.yml +++ b/auth-gateway/auth-gateway-app/src/main/resources/application.yml @@ -80,3 +80,22 @@ app: custom-user-language: enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} pre-auth-client-secret: ${PRE_AUTH_CLIENT_SECRET} + swagger: + cors: + enabled: true + allowed-origins: "http://localhost:8110" + +# --- Swagger / SpringDoc (env-driven) --- +springdoc: + api-docs: + enabled: ${SWAGGER_API_DOCS_ENABLED:false} + path: ${SWAGGER_API_DOCS_PATH:/v3/api-docs} + + swagger-ui: + enabled: ${SWAGGER_UI_ENABLED:false} + path: ${SWAGGER_UI_PATH:/swagger-ui.html} + try-it-out-enabled: ${SWAGGER_TRY_IT_OUT_ENABLED:true} + persist-authorization: ${SWAGGER_PERSIST_AUTHORIZATION:true} + display-request-duration: true + operations-sorter: method + tags-sorter: alpha diff --git a/common/src/main/kotlin/co/nilin/opex/common/utils/LanguageUtils.kt b/common/src/main/kotlin/co/nilin/opex/common/utils/LanguageUtils.kt index 9c04ab8db..fc2a0a0c5 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/utils/LanguageUtils.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/utils/LanguageUtils.kt @@ -10,7 +10,7 @@ object LanguageUtils { Mono.deferContextual { ctx -> Mono.just(ctx.getOrDefault("lang", getDefaultUserLanguage())!!) } fun getDefaultUserLanguage(): String { - return try { + return try { GlobalWebConfigCache.webConfig?.defaultLanguage?.toString() ?: UserLanguage.EN.toString() } catch (e: Exception) { UserLanguage.EN.toString() diff --git a/common/src/main/kotlin/co/nilin/opex/common/utils/LimitedInterval.kt b/common/src/main/kotlin/co/nilin/opex/common/utils/LimitedInterval.kt new file mode 100644 index 000000000..87a7bb7d6 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/utils/LimitedInterval.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.common.utils + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import java.util.concurrent.TimeUnit + +enum class LimitedInterval(val label: String, val unit: TimeUnit, val duration: Long) { + + Day("1d", TimeUnit.DAYS, 1), + Week("1w", TimeUnit.DAYS, 7), + Month("1M", TimeUnit.DAYS, 31), + Year("1Y", TimeUnit.DAYS, 365); + + private fun getOffsetTime() = unit.toMillis(duration) + + fun getDate() = Date(Date().time - getOffsetTime()) + + fun getTime() = Date().time - getOffsetTime() + + fun getLocalDateTime(): LocalDateTime = with(Instant.ofEpochMilli(getDate().time)) { + LocalDateTime.ofInstant(this, ZoneId.systemDefault()) + } + + companion object { + fun findByLabel(label: String): LimitedInterval? { + return values().find { it.label == label } + } + } + +} \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 556a9e475..3ab397629 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -86,4 +86,7 @@ services: ports: - "0.0.0.0:8099:8080" - "127.0.0.1:1057:5005" + swagger-docs: + ports: + - "0.0.0.0:8110:8080" diff --git a/docker-compose.yml b/docker-compose.yml index b5b7c0f25..bb427887e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -422,6 +422,10 @@ services: - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + - SWAGGER_API_DOCS_ENABLED=${SWAGGER_API_DOCS_ENABLED} + - SWAGGER_UI_ENABLED=${SWAGGER_UI_ENABLED} + - SWAGGER_AUTH_ENABLED=${SWAGGER_AUTH_ENABLED} + - SWAGGER_AUTH_AUTHORITY=${SWAGGER_AUTH_AUTHORITY} volumes: - auth-gateway-keys:/app/keys depends_on: @@ -517,6 +521,10 @@ services: - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + - SWAGGER_API_DOCS_ENABLED=${SWAGGER_API_DOCS_ENABLED} + - SWAGGER_UI_ENABLED=${SWAGGER_UI_ENABLED} + - SWAGGER_AUTH_ENABLED=${SWAGGER_AUTH_ENABLED} + - SWAGGER_AUTH_AUTHORITY=${SWAGGER_AUTH_AUTHORITY} depends_on: - consul - vault @@ -653,6 +661,21 @@ services: deploy: restart_policy: condition: on-failure + swagger-docs: + image: swaggerapi/swagger-ui:latest + profiles: + - docs + environment: + URLS: ${SWAGGER_DOCS_URLS} + URLS_PRIMARY_NAME: ${SWAGGER_DOCS_PRIMARY_NAME:-Opex API} + DOC_EXPANSION: "none" + DEFAULT_MODELS_EXPAND_DEPTH: "-1" + DISPLAY_REQUEST_DURATION: "true" + DEEP_LINKING: "true" + PERSIST_AUTHORIZATION: "true" + TRY_IT_OUT_ENABLED: "true" + FILTER: "true" + VALIDATOR_URL: "none" volumes: zookeeper-data: zookeeper-log: diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileApprovalRequestRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileApprovalRequestRepository.kt index 9b8486e8c..03478a113 100644 --- a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileApprovalRequestRepository.kt +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileApprovalRequestRepository.kt @@ -77,7 +77,7 @@ interface ProfileApprovalRequestRepository : ReactiveCrudRepository + ): Flow @Query("select * from profile_approval_request p where p.user_id = :userId order by create_date desc limit 1") fun findByUserId(userId: String): Mono diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt index 97dfba756..703419083 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt @@ -1,5 +1,6 @@ package co.nilin.opex.wallet.app.controller +import co.nilin.opex.wallet.app.dto.UserSwapTransactionRequest import co.nilin.opex.wallet.app.dto.UserTransactionRequest import co.nilin.opex.wallet.core.inout.AdminSwapResponse import co.nilin.opex.wallet.core.spi.ReservedTransferManager @@ -35,8 +36,7 @@ class AdvancedTransferAdminController { ) ) suspend fun getSwapHistory( - @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UserTransactionRequest + @RequestBody request: UserSwapTransactionRequest ): List? { return with(request) { diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferController.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferController.kt index e3ef85e7b..0d9016edd 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferController.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferController.kt @@ -4,6 +4,7 @@ import co.nilin.opex.common.OpexError import co.nilin.opex.wallet.app.dto.ReservedTransferResponse import co.nilin.opex.wallet.app.dto.TransferPreEvaluateResponse import co.nilin.opex.wallet.app.dto.TransferReserveRequest +import co.nilin.opex.wallet.app.dto.UserSwapTransactionRequest import co.nilin.opex.wallet.app.dto.UserTransactionRequest import co.nilin.opex.wallet.app.service.TransferService import co.nilin.opex.wallet.core.inout.SwapResponse @@ -140,7 +141,7 @@ class AdvancedTransferController { ) suspend fun getSwapHistory( @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UserTransactionRequest + @RequestBody request: UserSwapTransactionRequest ): List? { return with(request) { @@ -171,7 +172,7 @@ class AdvancedTransferController { @PostMapping("/v1/swap/history/count") suspend fun getSwapHistoryCount( @CurrentSecurityContext securityContext: SecurityContext, - @RequestBody request: UserTransactionRequest + @RequestBody request: UserSwapTransactionRequest ): Long { return with(request) { diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/TerminalAdminController.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/TerminalAdminController.kt new file mode 100644 index 000000000..9d8e9c06f --- /dev/null +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/TerminalAdminController.kt @@ -0,0 +1,98 @@ +package co.nilin.opex.wallet.app.controller + +import co.nilin.opex.wallet.app.dto.AdminSearchDepositRequest +import co.nilin.opex.wallet.app.dto.ManualTransferRequest +import co.nilin.opex.wallet.app.dto.TerminalLocalizationResponse +import co.nilin.opex.wallet.app.service.DepositService +import co.nilin.opex.wallet.core.inout.* +import co.nilin.opex.wallet.core.spi.GatewayTerminalManager +import co.nilin.opex.wallet.core.spi.TerminalManager +import io.swagger.annotations.ApiResponse +import io.swagger.annotations.Example +import io.swagger.annotations.ExampleProperty +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +@RestController +@RequestMapping("/admin/terminal") + +class TerminalAdminController( + private val depositService: DepositService, + private val terminalManager: TerminalManager, + private val gatewayTerminalManager: GatewayTerminalManager +) { + + @PostMapping("") + suspend fun registerTerminal( + @RequestBody body: TerminalCommand + ): TerminalCommand? { + return terminalManager.save(body.apply { uuid = UUID.randomUUID().toString() }) + } + + + @PutMapping("/{uuid}") + suspend fun updateTerminal( + @PathVariable("uuid") terminalUuid: String, + @RequestBody body: TerminalCommand + ): TerminalCommand? { + return terminalManager.update(body.apply { uuid = terminalUuid }) + } + + + @DeleteMapping("/{uuid}") + suspend fun deleteTerminal( + @PathVariable("uuid") terminalUuid: String, + ) { + terminalManager.delete(terminalUuid) + } + + @GetMapping("") + suspend fun getTerminal( + ): List? { + return terminalManager.fetchTerminal() + } + + @GetMapping("/{uuid}") + suspend fun getTerminal( + @PathVariable("uuid") terminalUuid: String, + ): TerminalCommand? { + return terminalManager.fetchTerminal(terminalUuid) + } + + @GetMapping("/{uuid}/gateway") + suspend fun getGatewayTerminal( + @PathVariable("uuid") terminalUuid: String, + ): List? { + return gatewayTerminalManager.getAssignedGatewayToTerminal(terminalUuid) + } + + @PostMapping("/{uuid}/localization") + suspend fun saveTerminalLocalization( + @PathVariable("uuid") terminalUuid: String, + @RequestBody terminalLocalizations: List + ): TerminalLocalizationResponse { + val terminalLocalizations = terminalManager.saveTerminalLocalizations(terminalUuid, terminalLocalizations) + return TerminalLocalizationResponse(terminalUuid, terminalLocalizations) + } + + @GetMapping("/{uuid}/localization") + suspend fun getTerminalLocalization( + @PathVariable("uuid") terminalUuid: String + ): TerminalLocalizationResponse { + val terminalLocalizations = terminalManager.fetchTerminalLocalizations(terminalUuid) + return TerminalLocalizationResponse(terminalUuid, terminalLocalizations) + } + + @DeleteMapping("/terminal/localization/{id}") + suspend fun deleteTerminalLocalization( + @PathVariable("id") id: Long, + ) { + terminalManager.deleteTerminalLocalizations(id) + } +} \ No newline at end of file diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/UserSwapTransactionRequest.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/UserSwapTransactionRequest.kt new file mode 100644 index 000000000..20a0a2335 --- /dev/null +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/UserSwapTransactionRequest.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.wallet.app.dto + +import co.nilin.opex.wallet.core.model.UserTransactionCategory +import co.nilin.opex.wallet.core.model.otc.ReservedStatus + +data class UserSwapTransactionRequest( + val userId: String? = null, + val currency: String?, + val sourceSymbol: String?, + val destSymbol: String?, + val startTime: Long? = null, + val endTime: Long? = null, + val limit: Int? = 10, + val offset: Int? = 0, + val ascendingByTime: Boolean = false, + val status: ReservedStatus? = ReservedStatus.Committed +) + diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/UserTransactionRequest.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/UserTransactionRequest.kt index df18304bf..d491efc0c 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/UserTransactionRequest.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/UserTransactionRequest.kt @@ -14,5 +14,5 @@ data class UserTransactionRequest( val limit: Int? = 10, val offset: Int? = 0, val ascendingByTime: Boolean = false, - val status: ReservedStatus? = ReservedStatus.Committed -) \ No newline at end of file +) + diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/DepositService.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/DepositService.kt index 7d758e1f5..86c96528d 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/DepositService.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/DepositService.kt @@ -208,15 +208,15 @@ class DepositService( // ------------------------------------------------------------------------- fun isValidDeposit(deposit: Deposit, gatewayData: GatewayData): Boolean { - return gatewayData.isEnabled && + return deposit.transferMethod == TransferMethod.MANUALLY || (gatewayData.isEnabled && deposit.amount >= gatewayData.minimum && - deposit.amount <= gatewayData.maximum + deposit.amount <= gatewayData.maximum) } suspend fun fetchDepositData( gatewayUuid: String?, symbol: String, - depositType: co.nilin.opex.wallet.core.model.DepositType, + depositType: DepositType, depositCommand: Deposit, ): GatewayData { diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/WithdrawType.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/WithdrawType.kt index 98ae7851c..2f93455f3 100644 --- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/WithdrawType.kt +++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/WithdrawType.kt @@ -1,5 +1,5 @@ package co.nilin.opex.wallet.core.inout enum class WithdrawType { - CARD_TO_CARD, SHEBA, ON_CHAIN + OFF_CHAIN, ON_CHAIN } \ No newline at end of file diff --git a/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TerminalManagerImpl.kt b/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TerminalManagerImpl.kt index bc41de604..6da9be671 100644 --- a/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TerminalManagerImpl.kt +++ b/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TerminalManagerImpl.kt @@ -68,9 +68,11 @@ class TerminalManagerImpl( } override suspend fun delete(uuid: String) { - loadTerminal(uuid)?.let { - terminalRepository.deleteById(it.id!!).awaitSingleOrNull() - } ?: throw OpexError.TerminalNotFound.exception() + val terminal = loadTerminal(uuid) + if (terminal != null) + terminalRepository.deleteById(terminal.id!!).awaitSingleOrNull() + else + throw OpexError.TerminalNotFound.exception() } override suspend fun fetchTerminal(): List? {