diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea782..320592668 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,6 +12,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,15 +25,17 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/.github/workflows/dev-otc.yml b/.github/workflows/dev-otc.yml index b05c794ba..9aa39b90e 100644 --- a/.github/workflows/dev-otc.yml +++ b/.github/workflows/dev-otc.yml @@ -1,8 +1,8 @@ -name: Build, Test, and Deploy otc (DEV env) services for specific partner +name: Build, Test, and Deploy otc (DEV env) services for specific partner on: -# push: -# branches: -# - dev + # push: + # branches: + # - dev workflow_dispatch: inputs: @@ -29,10 +29,9 @@ jobs: uses: actions/checkout@v2 - name: Setup Java - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-package: jdk + distribution: temurin java-version: ${{ matrix.java }} - name: Fetch partners config @@ -69,7 +68,6 @@ jobs: echo "PASSWORD=$PASSWORD" >> $GITHUB_OUTPUT echo "ENV_PATH=$ENV_PATH" >> $GITHUB_OUTPUT echo "SSH_PORT=$SSH_PORT" >> $GITHUB_OUTPUT - - name: Decrypt data id: decrypt_data @@ -107,9 +105,9 @@ jobs: echo "password=$server_pass" >> $GITHUB_OUTPUT - name: Build - run: | - mvn -pl common -am -B -T 1C clean install -Potc - mvn -pl wallet,bc-gateway -amd -B -T 1C clean install -Potc + run: | + mvn -pl common -am -B clean install -Potc + mvn -pl wallet,bc-gateway -amd -B clean install -Potc - name: Run Tests run: | diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 8cd20f339..a0409266b 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -16,16 +16,15 @@ jobs: - name: Checkout Source Code uses: actions/checkout@v2 - name: Setup Java - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-package: jdk + distribution: temurin java-version: ${{ matrix.java }} -# cache: maven + # cache: maven - name: Build - run: mvn -B -T 1C clean install + run: mvn -B clean install - name: Run Tests - run: mvn -B -T 1C -Dskip.unit.tests=false surefire:test + run: mvn -B -Dskip.unit.tests=false surefire:test - name: Build Docker images env: TAG: dev diff --git a/.github/workflows/main-otc.yml b/.github/workflows/main-otc.yml index 4a0d3ca45..16c18ea9c 100644 --- a/.github/workflows/main-otc.yml +++ b/.github/workflows/main-otc.yml @@ -1,8 +1,8 @@ -name: Build, Test, and Deploy otc (PRD env) services for specific partner +name: Build, Test, and Deploy otc (PRD env) services for specific partner on: -# push: -# branches: -# - main + # push: + # branches: + # - main workflow_dispatch: inputs: @@ -29,10 +29,9 @@ jobs: uses: actions/checkout@v2 - name: Setup Java - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-package: jdk + distribution: temurin java-version: ${{ matrix.java }} - name: Fetch partners config @@ -107,14 +106,14 @@ jobs: echo "password=$server_pass" >> $GITHUB_OUTPUT - name: Build - run: | + run: | mvn -pl common -am -B -T 1C clean install -Potc mvn -pl wallet,bc-gateway -amd -B -T 1C clean install -Potc - name: Run Tests run: | - mvn -pl common -am -B -T 1C -Dskip.unit.tests=false surefire:test - mvn -pl wallet,bc-gateway -amd -B -T 1C -Dskip.unit.tests=false surefire:test + mvn -pl common -am -B -Dskip.unit.tests=false surefire:test + mvn -pl wallet,bc-gateway -amd -B -Dskip.unit.tests=false surefire:test - name: Build Docker images run: docker compose -f docker-compose-otc.build.yml build diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 382536bdb..331e18fb0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,16 +16,15 @@ jobs: - name: Checkout Source Code uses: actions/checkout@v2 - name: Setup Java - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-package: jdk + distribution: temurin java-version: ${{ matrix.java }} -# cache: maven + # cache: maven - name: Build - run: mvn -B -T 1C clean install + run: mvn -B clean install - name: Run Tests - run: mvn -B -T 1C -Dskip.unit.tests=false surefire:test + run: mvn -B -Dskip.unit.tests=false surefire:test - name: Build Docker images env: TAG: latest diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ec4f902d6..74af2bad1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,17 +15,16 @@ jobs: - name: Checkout Source Code uses: actions/checkout@v2 - name: Setup Java - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: distribution: temurin java-version: ${{ matrix.java }} - - name: Verify Docker - run: docker info - name: Build - run: mvn -B -T 1C clean install -Potc + run: mvn -B clean install -Potc - name: Run Tests - run: mvn -B -T 1C -Dskip.unit.tests=false surefire:test + run: mvn -B -Dskip.unit.tests=false surefire:test + - name: Build Docker images env: TAG: pr - run: docker compose -f docker-compose.build.yml build + run: docker compose -f docker-compose.build.yml build \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f42c10ed..05f9a75c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,12 @@ on: # branches: # - dev workflow_dispatch: - +# inputs: +# partner_name: +# type: string +# description: 'The name of the partner (provided during workflow execution)' +# required: true +# default: default jobs: build: runs-on: ubuntu-22.04 @@ -17,10 +22,9 @@ jobs: - name: Checkout Source Code uses: actions/checkout@v2 - name: Setup Java - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-package: jdk + distribution: temurin java-version: ${{ matrix.java }} # cache: maven - name: Build diff --git a/.gitignore b/.gitignore index cc293eb45..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 @@ -44,3 +44,4 @@ build/ !/.mvn/ .DS_Store +realms \ No newline at end of file diff --git a/README.md b/README.md index f15258b05..c10eb526a 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,12 @@ PANEL_PASS=admin BACKEND_USER=admin KEYCLOAK_ADMIN_USERNAME=opex KEYCLOAK_ADMIN_PASSWORD=hiopex -SMTP_PASS=x +SMTP_PASSWORD=x API_KEY_CLIENT_SECRET=x KEYCLOAK_FRONTEND_URL=http://localhost:8083/auth KEYCLOAK_ADMIN_URL=http://localhost:8083/auth KEYCLOAK_VERIFY_REDIRECT_URL=http://localhost:8080/verify KEYCLOAK_FORGOT_REDIRECT_URL=http://localhost:8080/forgot -PREFERENCES=preferences-demo.yml WHITELIST_REGISTER_ENABLED=true WHITELIST_LOGIN_ENABLED=true WALLET_BACKUP_ENABLED=false @@ -77,10 +76,9 @@ TAG=debug | BACKEND_USER | Username used by services to access vault data. Also used as the username for Vault admin panel | | KEYCLOAK_ADMIN_USERNAME | Username for Keycloak admin panel | | KEYCLOAK_ADMIN_PASSWORD | Password for Keycloak admin panel | -| SMTP_PASS | SMTP password used by keycloak to send emails for various operations (e.g. user verification, reset password) | +| SMTP_PASSWORD | SMTP password used by keycloak to send emails for various operations (e.g. user verification, reset password) | | API_KEY_CLIENT_SECRET | In order to access the api key feature, please follow the steps below:
1. Go to Keycloak admin panel located at http://localhost:8083/auth/admin/master/console/#/realms/opex/clients
2. Login with the username and password you provided in the `.env` file (KEYCLOAK_ADMIN_USERNAME and KEYCLOAK_ADMIN_PASSWORD)
3. Go to `clients` section on the left menu
4. Click on `opex-api-key` client
5. In the credentials section, click on `Regenerate Secret` button
6. Copy the generated secret and paste it into this section | | KEYCLOAK_FRONTEND_URL
KEYCLOAK_ADMIN_URL
KEYCLOAK_VERIFY_REDIRECT_URL
KEYCLOAK_FORGOT_REDIRECT_URL | Replace `localhost` with your server's IP if you're not running on local machine. Do not change the rest. | -| PREFERENCES | Points to a file containing seed data used to by modules to initialize their databases. An example of this file is provided and is available inside the root directory (preferences-demo.yml). It's deprecated and will be removed soon | | WHITELIST_REGISTER_ENABLED | Allows registration only for whitelisted emails | | WHITELIST_LOGIN_ENABLED | Allows login only for whitelisted emails | | WALLET_BACKUP_ENABLED | Enables wallet data backup to google drive folder. In order to use this feature, you need to have `drive-key.json` file (obtained from google drive API panel) in the root directory of project | diff --git a/accountant/accountant-app/Dockerfile b/accountant/accountant-app/Dockerfile index 9c566c1b2..29f0c2703 100644 --- a/accountant/accountant-app/Dockerfile +++ b/accountant/accountant-app/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11 +FROM eclipse-temurin:17-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] diff --git a/accountant/accountant-app/pom.xml b/accountant/accountant-app/pom.xml index f722891eb..f56aee06a 100644 --- a/accountant/accountant-app/pom.xml +++ b/accountant/accountant-app/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -51,6 +51,10 @@ co.nilin.opex.utility error-handler + + co.nilin.opex.utility + interceptors + org.springframework.cloud spring-cloud-starter-vault-config @@ -64,10 +68,6 @@ micrometer-registry-prometheus runtime - - co.nilin.opex.utility - preferences - org.testcontainers testcontainers diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/AccountantApp.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/AccountantApp.kt index 02bc1d88d..ad5272a2c 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/AccountantApp.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/AccountantApp.kt @@ -1,7 +1,6 @@ package co.nilin.opex.accountant.app import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.context.annotation.ComponentScan diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt index f63ed9800..4a160cefb 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt @@ -1,10 +1,6 @@ package co.nilin.opex.accountant.app.config import co.nilin.opex.accountant.app.listener.* -import co.nilin.opex.accountant.app.listener.AccountantEventListener -import co.nilin.opex.accountant.app.listener.AccountantTempEventListener -import co.nilin.opex.accountant.app.listener.AccountantTradeListener -import co.nilin.opex.accountant.app.listener.OrderListener import co.nilin.opex.accountant.core.api.FeeCalculator import co.nilin.opex.accountant.core.api.FinancialActionJobManager import co.nilin.opex.accountant.core.api.OrderManager @@ -14,19 +10,21 @@ import co.nilin.opex.accountant.core.service.OrderManagerImpl import co.nilin.opex.accountant.core.service.TradeManagerImpl import co.nilin.opex.accountant.core.spi.* import co.nilin.opex.accountant.ports.kafka.listener.consumer.* -import co.nilin.opex.accountant.ports.kafka.listener.consumer.EventKafkaListener -import co.nilin.opex.accountant.ports.kafka.listener.consumer.OrderKafkaListener -import co.nilin.opex.accountant.ports.kafka.listener.consumer.TempEventKafkaListener -import co.nilin.opex.accountant.ports.kafka.listener.consumer.TradeKafkaListener import co.nilin.opex.accountant.ports.kafka.listener.spi.FAResponseListener import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.scheduling.annotation.EnableScheduling @Configuration @EnableScheduling -class AppConfig { +class AppConfig( + @Value("\${app.trade-volume-calculation-currency}") + private val tradeVolumeCalculationCurrency: String, + @Value("\${app.zone-offset}") + private val zoneOffsetString: String, +) { @Bean fun getFinancialActionJobManager( @@ -44,7 +42,6 @@ class AppConfig { @Bean fun orderManager( pairConfigLoader: PairConfigLoader, - userLevelLoader: UserLevelLoader, financialActionPersister: FinancialActionPersister, financeActionLoader: FinancialActionLoader, orderPersister: OrderPersister, @@ -52,18 +49,17 @@ class AppConfig { tempEventRepublisher: TempEventRepublisher, richOrderPublisher: RichOrderPublisher, financialActionPublisher: FinancialActionPublisher, - jsonMapper: JsonMapper + feeCalculator: FeeCalculator ): OrderManager { return OrderManagerImpl( pairConfigLoader, - userLevelLoader, financialActionPersister, financeActionLoader, orderPersister, tempEventPersister, richOrderPublisher, financialActionPublisher, - jsonMapper + feeCalculator ) } @@ -77,7 +73,8 @@ class AppConfig { richOrderPublisher: RichOrderPublisher, feeCalculator: FeeCalculator, financialActionPublisher: FinancialActionPublisher, - jsonMapper: JsonMapper + currencyRatePersister: CurrencyRatePersister, + userVolumePersister: UserTradeVolumePersister ): TradeManager { return TradeManagerImpl( financeActionPersister, @@ -88,7 +85,10 @@ class AppConfig { richOrderPublisher, feeCalculator, financialActionPublisher, - jsonMapper + currencyRatePersister, + userVolumePersister, + tradeVolumeCalculationCurrency, + zoneOffsetString ) } @@ -137,18 +137,32 @@ class AppConfig { fun configureEventListener( eventKafkaListener: EventKafkaListener, accountantEventListener: AccountantEventListener, - kycLevelUpdatedKafkaListener: KycLevelUpdatedKafkaListener, - kycLevelUpdatedEventListener: KycLevelUpdatedListener ) { eventKafkaListener.addListener(accountantEventListener) - kycLevelUpdatedKafkaListener.addEventListener(kycLevelUpdatedEventListener) } @Autowired fun configureTempEventListener( tempEventKafkaListener: TempEventKafkaListener, - accountantTempEventListener: AccountantTempEventListener) { + accountantTempEventListener: AccountantTempEventListener + ) { tempEventKafkaListener.addListener(accountantTempEventListener) } + @Autowired + fun configureWithdrawRequestEventListener( + withdrawRequestKafkaListener: WithdrawRequestKafkaListener, + withdrawRequestEventListener: WithdrawRequestEventListener + ) { + withdrawRequestKafkaListener.addListener(withdrawRequestEventListener) + } + + + @Autowired + fun configureDepositEventListener( + depositKafkaListener: DepositKafkaListener, + depositEventListener: DepositEventListener + ) { + depositKafkaListener.addListener(depositEventListener) + } } \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt index 20d751529..8e9b73c94 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt @@ -1,61 +1,18 @@ package co.nilin.opex.accountant.app.config -import co.nilin.opex.accountant.ports.postgres.dao.PairConfigRepository -import co.nilin.opex.accountant.ports.postgres.dao.PairFeeConfigRepository -import co.nilin.opex.accountant.ports.postgres.dao.UserLevelRepository -import co.nilin.opex.accountant.ports.postgres.model.PairFeeConfigModel -import co.nilin.opex.utility.preferences.Preferences -import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.runBlocking -import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.DependsOn import org.springframework.stereotype.Component import javax.annotation.PostConstruct @Component @DependsOn("postgresConfig") -class InitializeService( - private val pairConfigRepository: PairConfigRepository, - private val pairFeeConfigRepository: PairFeeConfigRepository, - private val userLevelRepository: UserLevelRepository, -) { - - @Autowired - private lateinit var preferences: Preferences +class InitializeService{ @PostConstruct fun init() = runBlocking { - preferences.userLevels.forEach { - userLevelRepository.insert(it).awaitSingleOrNull() - } - - preferences.markets.map { - val pair = it.pair ?: "${it.leftSide}_${it.rightSide}" - val leftSideCurrency = preferences.currencies.first { c -> it.leftSide == c.symbol } - val rightSideCurrency = preferences.currencies.first { c -> it.rightSide == c.symbol } - val leftSideFraction = (it.leftSideFraction ?: leftSideCurrency.precision) - val rightSideFraction = (it.rightSideFraction ?: rightSideCurrency.precision) - pairConfigRepository.insert( - pair, - it.leftSide, - it.rightSide, - leftSideFraction, - rightSideFraction - ).awaitSingleOrNull() - it.feeConfigs.forEach { f -> - runCatching { - pairFeeConfigRepository.save( - PairFeeConfigModel( - null, - pair, - f.direction, - f.userLevel, - f.makerFee, - f.takerFee - ) - ).awaitSingleOrNull() - } - } - } + // addUserLevels() + // addPairConfigs() + // addPairFeeConfigs } } diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/SecurityConfig.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/SecurityConfig.kt new file mode 100644 index 000000000..dd1dc06dd --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/SecurityConfig.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.accountant.app.config + +import org.springframework.context.annotation.Bean +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.web.server.SecurityWebFilterChain + +@EnableWebFluxSecurity +class SecurityConfig { + + @Bean + fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + http.csrf().disable() + .authorizeExchange() + .anyExchange().permitAll() + return http.build() + } +} \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/AccountantController.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/AccountantController.kt index 3971bbae2..9b0aa7200 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/AccountantController.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/AccountantController.kt @@ -3,6 +3,7 @@ package co.nilin.opex.accountant.app.controller import co.nilin.opex.accountant.core.model.WalletType import co.nilin.opex.accountant.core.spi.FinancialActionLoader import co.nilin.opex.accountant.core.spi.WalletProxy +import co.nilin.opex.accountant.core.spi.WithdrawLimitManager import co.nilin.opex.accountant.ports.walletproxy.data.BooleanResponse import co.nilin.opex.matching.engine.core.eventh.events.SubmitOrderEvent import org.slf4j.LoggerFactory @@ -14,8 +15,10 @@ import java.math.BigDecimal @RestController class AccountantController( val walletProxy: WalletProxy, - val financialActionLoader: FinancialActionLoader -) { + val financialActionLoader: FinancialActionLoader, + val withdrawLimitManager: WithdrawLimitManager, + + ) { private val logger = LoggerFactory.getLogger(AccountantController::class.java) @@ -36,5 +39,15 @@ class AccountantController( BooleanResponse(false) } + @GetMapping("{uuid}/{level}/request_withdraw/{amount}_{currency}/allowed") + suspend fun canRequestWithdraw( + @PathVariable("uuid") uuid: String, + @PathVariable("level") userLevel: String, + @PathVariable("currency") currency: String, + @PathVariable("amount") amount: BigDecimal + ): Boolean { + return withdrawLimitManager.canWithdraw(uuid, userLevel, currency, amount) + } + } \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/ConfigController.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/ConfigController.kt new file mode 100644 index 000000000..ed04a79fc --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/ConfigController.kt @@ -0,0 +1,45 @@ +package co.nilin.opex.accountant.app.controller + +import co.nilin.opex.accountant.core.model.FeeConfig +import co.nilin.opex.accountant.core.model.PairConfig +import co.nilin.opex.accountant.core.model.WithdrawLimitConfig +import co.nilin.opex.accountant.core.spi.FeeConfigService +import co.nilin.opex.accountant.core.spi.PairConfigLoader +import co.nilin.opex.accountant.core.spi.WithdrawLimitManager +import co.nilin.opex.matching.engine.core.model.OrderDirection +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/config") +class ConfigController( + private val pairConfigLoader: PairConfigLoader, + private val feeConfigService: FeeConfigService, + private val withdrawLimitManager: WithdrawLimitManager +) { + + @GetMapping("/{pair}/{direction}") + suspend fun fetchPairConfig( + @PathVariable("pair") pair: String, + @PathVariable("direction") direction: OrderDirection + ): PairConfig { + return pairConfigLoader.load(pair, direction) + } + + @GetMapping("/all") + suspend fun fetchPairConfigs(): List { + return pairConfigLoader.loadPairConfigs() + } + + @GetMapping("/fee") + suspend fun getFeeConfigs(): List { + return feeConfigService.loadFeeConfigs() + } + + @GetMapping("/withdraw-limit") + suspend fun getWithdrawLimitConfigs(): List { + return withdrawLimitManager.getAll() + } +} \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/PairConfigController.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/PairConfigController.kt deleted file mode 100644 index 962218b17..000000000 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/PairConfigController.kt +++ /dev/null @@ -1,53 +0,0 @@ -package co.nilin.opex.accountant.app.controller - -import co.nilin.opex.accountant.app.data.PairFeeResponse -import co.nilin.opex.accountant.core.model.PairConfig -import co.nilin.opex.accountant.core.model.PairFeeConfig -import co.nilin.opex.accountant.core.spi.PairConfigLoader -import co.nilin.opex.common.OpexError -import co.nilin.opex.matching.engine.core.model.OrderDirection -import org.springframework.web.bind.annotation.* - -@RestController -@RequestMapping("/config") -class PairConfigController(private val pairConfigLoader: PairConfigLoader) { - - @GetMapping("/{pair}/fee/{direction}-{userLevel}") - suspend fun fetchPairFeeConfig( - @PathVariable("pair") pair: String, - @PathVariable("direction") direction: OrderDirection, - @PathVariable("userLevel") level: String - ): PairFeeConfig { - return pairConfigLoader.load(pair, direction, level) - } - - @GetMapping("/{pair}/{direction}") - suspend fun fetchPairConfig( - @PathVariable("pair") pair: String, - @PathVariable("direction") direction: OrderDirection - ): PairConfig { - return pairConfigLoader.load(pair, direction) - } - - @GetMapping("/all") - suspend fun fetchPairConfigs(): List { - return pairConfigLoader.loadPairConfigs() - } - - @GetMapping("/fee") - suspend fun getFeeConfigs(): List { - return pairConfigLoader.loadPairFeeConfigs() - .map { PairFeeResponse(it.pairConfig.pair, it.direction, it.userLevel, it.makerFee, it.takerFee) } - } - - @GetMapping("/fee/{pair}") - suspend fun getFeeConfig( - @PathVariable pair: String, - @RequestParam(required = false) direction: OrderDirection?, - @RequestParam(required = false) userLevel: String? - ): PairFeeResponse { - val fee = pairConfigLoader.loadPairFeeConfigs(pair, direction ?: OrderDirection.BID, userLevel ?: "*") - ?: throw OpexError.PairFeeNotFound.exception() - return PairFeeResponse(fee.pairConfig.pair, fee.direction, fee.userLevel, fee.makerFee, fee.takerFee) - } -} \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/UserDailyActivityController.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/UserDailyActivityController.kt new file mode 100644 index 000000000..e447f7646 --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/UserDailyActivityController.kt @@ -0,0 +1,46 @@ +package co.nilin.opex.accountant.app.controller + +import co.nilin.opex.accountant.core.api.DepositActivityManager +import co.nilin.opex.accountant.core.api.TradeActivityManager +import co.nilin.opex.accountant.core.api.WithdrawActivityManager +import co.nilin.opex.accountant.core.model.DailyAmount +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/user-activity") +class UserDailyActivityController( + private val withdrawManager: WithdrawActivityManager, + private val depositManager: DepositActivityManager, + private val TradeManager: TradeActivityManager +) { + + @GetMapping("/withdraw/{userId}") + suspend fun getDailyWithdrawLast31Days( + @PathVariable userId: String + ): List { + return withdrawManager.getLastDaysWithdrawActivity( + userId = userId + ) + } + + @GetMapping("/deposit/{userId}") + suspend fun getDailyDepositLast31Days( + @PathVariable userId: String + ): List { + return depositManager.getLastDaysDepositActivity( + userId = userId + ) + } + + @GetMapping("/trade/{userId}") + suspend fun getDailyTradeLast31Days( + @PathVariable userId: String + ): List { + return TradeManager.getLastDaysTradeActivity( + userId = userId + ) + } +} \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/UserDataController.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/UserDataController.kt new file mode 100644 index 000000000..4066e8730 --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/controller/UserDataController.kt @@ -0,0 +1,64 @@ +package co.nilin.opex.accountant.app.controller + +import co.nilin.opex.accountant.core.api.FeeCalculator +import co.nilin.opex.accountant.core.model.UserFee +import co.nilin.opex.accountant.core.spi.UserTradeVolumePersister +import co.nilin.opex.accountant.core.spi.UserWithdrawVolumePersister +import co.nilin.opex.common.utils.Interval +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.* +import java.math.BigDecimal +import java.time.LocalDateTime + +@RestController +@RequestMapping("/user/data") +class UserDataController( + private val userVolumePersister: UserTradeVolumePersister, + private val feeCalculator: FeeCalculator, + private val userWithdrawVolumePersister: UserWithdrawVolumePersister, + @Value("\${app.trade-volume-calculation-currency}") + private val tradeVolumeCalculationCurrency: String, +) { + @GetMapping("/fee/{uuid}") + suspend fun getUserFee(@PathVariable uuid: String): UserFee { + return feeCalculator.getUserFee(uuid) + } + + @GetMapping("/trade/volume/{uuid}") + suspend fun getTradeVolumeByCurrency( + @PathVariable uuid: String, + @RequestParam symbol: String, + @RequestParam interval: Interval + ): BigDecimal { + return userVolumePersister.getUserTotalTradeVolumeByCurrency( + uuid, + symbol, + interval.getLocalDateTime().toLocalDate(), + tradeVolumeCalculationCurrency + ) ?: BigDecimal.ZERO + } + + @GetMapping("/trade/volume/total/{uuid}") + suspend fun getTotalTradeVolumeValue( + @PathVariable uuid: String, + @RequestParam interval: Interval + ): BigDecimal { + return userVolumePersister.getUserTotalTradeVolume( + uuid, + interval.getLocalDateTime().toLocalDate(), + tradeVolumeCalculationCurrency + ) ?: BigDecimal.ZERO + } + + @GetMapping("/withdraw/volume/total/{uuid}") + suspend fun getWithdrawVolumeValue( + @PathVariable uuid: String, + @RequestParam(required = false) interval: Interval? + ): BigDecimal { + return userWithdrawVolumePersister.getTotalValueByUserAndDateAfter( + uuid, + (interval?.getLocalDateTime() ?: LocalDateTime.now()) + ) + } + +} \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/data/PairFeeResponse.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/data/PairFeeResponse.kt index cbfb4c63f..6ac8d804b 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/data/PairFeeResponse.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/data/PairFeeResponse.kt @@ -3,7 +3,7 @@ package co.nilin.opex.accountant.app.data import java.math.BigDecimal data class PairFeeResponse( - val pair:String, + val pair: String, val direction: String, val userLevel: String, val makerFee: BigDecimal, diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/DepositEventListener.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/DepositEventListener.kt new file mode 100644 index 000000000..01a2662d0 --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/DepositEventListener.kt @@ -0,0 +1,38 @@ +package co.nilin.opex.accountant.app.listener + +import co.nilin.opex.accountant.core.spi.UserDepositVolumePersister +import co.nilin.opex.accountant.ports.kafka.listener.inout.DepositEvent +import co.nilin.opex.accountant.ports.kafka.listener.inout.WithdrawRequestEvent +import co.nilin.opex.accountant.ports.kafka.listener.spi.DepositListener +import co.nilin.opex.accountant.ports.kafka.listener.spi.WithdrawRequestListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class DepositEventListener(private val userDepositVolumePersister: UserDepositVolumePersister) : + DepositListener { + + private val logger = LoggerFactory.getLogger(DepositEventListener::class.java) + val scope = CoroutineScope(Dispatchers.IO) + override fun id(): String { + return "DepositEventListener" + } + + override fun onEvent( + event: DepositEvent, + partition: Int, + offset: Long, + timestamp: Long + ) { + logger.info("==========================================================================") + logger.info("Incoming Deposit event: $event") + logger.info("==========================================================================") + scope.launch { + userDepositVolumePersister.update(event.uuid, event.currency, event.amount, event.createDate) + } + } + +} diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/WithdrawRequestEventListener.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/WithdrawRequestEventListener.kt new file mode 100644 index 000000000..d2a3ace87 --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/WithdrawRequestEventListener.kt @@ -0,0 +1,36 @@ +package co.nilin.opex.accountant.app.listener + +import co.nilin.opex.accountant.core.spi.UserWithdrawVolumePersister +import co.nilin.opex.accountant.ports.kafka.listener.inout.WithdrawRequestEvent +import co.nilin.opex.accountant.ports.kafka.listener.spi.WithdrawRequestListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class WithdrawRequestEventListener(private val userWithdrawVolumePersister: UserWithdrawVolumePersister) : + WithdrawRequestListener { + + private val logger = LoggerFactory.getLogger(WithdrawRequestEventListener::class.java) + val scope = CoroutineScope(Dispatchers.IO) + override fun id(): String { + return "WithdrawRequestEventListener" + } + + override fun onEvent( + event: WithdrawRequestEvent, + partition: Int, + offset: Long, + timestamp: Long + ) { + logger.info("==========================================================================") + logger.info("Incoming WithdrawRequest event: $event") + logger.info("==========================================================================") + scope.launch { + userWithdrawVolumePersister.update(event.uuid, event.currency, event.amount, event.createDate, event.status) + } + } + +} diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/scheduler/CurrencyPriceJob.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/scheduler/CurrencyPriceJob.kt new file mode 100644 index 000000000..149b5e71f --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/scheduler/CurrencyPriceJob.kt @@ -0,0 +1,42 @@ +package co.nilin.opex.accountant.app.scheduler + +import co.nilin.opex.accountant.core.spi.CurrencyRatePersister +import co.nilin.opex.accountant.core.spi.WalletProxy +import kotlinx.coroutines.runBlocking +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.math.BigDecimal + + +@Service +class CurrencyPriceJob( + private val currencyRatePersister: CurrencyRatePersister, + private val walletProxy: WalletProxy, + @Value("\${app.trade-volume-calculation-currency}") + private val tradeVolumeCalculationCurrency: String, + @Value("\${app.withdraw-volume-calculation-currency}") + private val withdrawVolumeCalculationCurrency: String +) { + + @Scheduled(fixedDelay = 120_000, initialDelay = 120_000) + fun updatePrices() { + runBlocking { + updateCurrencyRates(tradeVolumeCalculationCurrency) + + if (tradeVolumeCalculationCurrency != withdrawVolumeCalculationCurrency) { + updateCurrencyRates(withdrawVolumeCalculationCurrency) + } + } + } + private suspend fun updateCurrencyRates(quoteCurrency: String) { + walletProxy.getPrices(quoteCurrency) + .forEach { price -> + currencyRatePersister.updateRate( + price.currency, + quoteCurrency, + price.sellPrice ?: BigDecimal.ZERO + ) + } + } +} diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/utils/Extensions.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/utils/Extensions.kt new file mode 100644 index 000000000..e9914a2cb --- /dev/null +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/utils/Extensions.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.accountant.app.utils + +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +fun LocalDateTime.asDate(): Date { + return Date.from(atZone(ZoneId.systemDefault()).toInstant()) +} + +fun Date.asLocalDateTime(): LocalDateTime { + return LocalDateTime.ofInstant(toInstant(), ZoneId.systemDefault()) +} + +fun Long.asLocalDateTime(): LocalDateTime { + return LocalDateTime.ofInstant(Date(this).toInstant(), ZoneId.systemDefault()) +} \ No newline at end of file diff --git a/accountant/accountant-app/src/main/resources/application.yml b/accountant/accountant-app/src/main/resources/application.yml index 25ba3928d..1f08d8860 100644 --- a/accountant/accountant-app/src/main/resources/application.yml +++ b/accountant/accountant-app/src/main/resources/application.yml @@ -16,12 +16,13 @@ spring: password: ${dbpassword:hiopex} initialization-mode: always pool: - #max-idle-time: 5m - #max-life-time: 5m - #max-acquire-time: 5m - initial-size: 10 + initial-size: 5 max-size: 32 - validation-query: select 1 + max-idle-time: 10m + max-life-time: 30m + max-acquire-time: 10s + max-create-connection-time: 10s + validation-query: SELECT 1 cloud: bootstrap: enabled: true @@ -103,4 +104,22 @@ app: retry: count: 10 delay-seconds: 5 - delay-multiplier: 3 \ No newline at end of file + delay-multiplier: 3 + zone-offset: +03:30 + trade-volume-calculation-currency: ${TRADE_VOLUME_CALCULATION_CURRENCY:USDT} + withdraw-volume-calculation-currency: ${WITHDRAW_VOLUME_CALCULATION_CURRENCY:USDT} + deposit-volume-calculation-currency: ${DEPOSIT_VOLUME_CALCULATION_CURRENCY:USDT} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + 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/accountant/accountant-app/src/test/resources/application.yml b/accountant/accountant-app/src/test/resources/application.yml index 005d0e363..f495cb20e 100644 --- a/accountant/accountant-app/src/test/resources/application.yml +++ b/accountant/accountant-app/src/test/resources/application.yml @@ -38,7 +38,7 @@ management: web: base-path: /actuator exposure: - include: ["health", "metrics"] + include: [ "health", "metrics" ] endpoint: health: show-details: always @@ -54,4 +54,8 @@ app: retry: count: 10 delay-seconds: 5 - delay-multiplier: 3 \ No newline at end of file + delay-multiplier: 3 + zone-offset: +03:30 + trade-volume-calculation-currency: USDT + withdraw-volume-calculation-currency: USDT + deposit-volume-calculation-currency: USDT \ No newline at end of file diff --git a/accountant/accountant-core/pom.xml b/accountant/accountant-core/pom.xml index fe9705d0c..9e689f4d0 100644 --- a/accountant/accountant-core/pom.xml +++ b/accountant/accountant-core/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -66,5 +66,11 @@ jackson-datatype-jsr310 2.15.2 + + com.sun.mail + mailapi + 1.6.2 + compile + diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/DepositActivityManager.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/DepositActivityManager.kt new file mode 100644 index 000000000..b655d77ac --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/DepositActivityManager.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.accountant.core.api + +import co.nilin.opex.accountant.core.model.DailyAmount + +interface DepositActivityManager { + suspend fun getLastDaysDepositActivity( + userId: String, + quoteCurrency: String? = null, + n: Int = 31 + ): List +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/FeeCalculator.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/FeeCalculator.kt index 97adf6933..205bf336b 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/FeeCalculator.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/FeeCalculator.kt @@ -3,10 +3,13 @@ package co.nilin.opex.accountant.core.api import co.nilin.opex.accountant.core.model.FeeFinancialActions import co.nilin.opex.accountant.core.model.FinancialAction import co.nilin.opex.accountant.core.model.Order +import co.nilin.opex.accountant.core.model.UserFee import co.nilin.opex.matching.engine.core.eventh.events.TradeEvent interface FeeCalculator { + suspend fun getUserFee(uuid: String): UserFee + suspend fun createFeeActions( trade: TradeEvent, makerOrder: Order, diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/TradeActivityManager.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/TradeActivityManager.kt new file mode 100644 index 000000000..c19491256 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/TradeActivityManager.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.accountant.core.api + +import co.nilin.opex.accountant.core.model.DailyAmount + +interface TradeActivityManager { + suspend fun getLastDaysTradeActivity( + userId: String, + quoteCurrency: String? = null, + n: Int = 31 + ): List +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/WithdrawActivityManager.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/WithdrawActivityManager.kt new file mode 100644 index 000000000..c21b4eb56 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/api/WithdrawActivityManager.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.accountant.core.api + +import co.nilin.opex.accountant.core.model.DailyAmount + +interface WithdrawActivityManager { + suspend fun getLastDaysWithdrawActivity( + userId: String, + quoteCurrency: String? = null, + n: Int = 31 + ): List +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/CurrencyPrice.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/CurrencyPrice.kt new file mode 100644 index 000000000..35308b1eb --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/CurrencyPrice.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.accountant.core.model + +import java.math.BigDecimal + +data class CurrencyPrice(var currency: String, val buyPrice: BigDecimal? = null, var sellPrice: BigDecimal? = null) diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/DailyAmount.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/DailyAmount.kt new file mode 100644 index 000000000..c30d8c689 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/DailyAmount.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.accountant.core.model + +import java.math.BigDecimal +import java.time.LocalDate + +data class DailyAmount( + val date: LocalDate, + val totalAmount: BigDecimal +) diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/FeeConfig.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/FeeConfig.kt new file mode 100644 index 000000000..00df04871 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/FeeConfig.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.accountant.core.model + +import java.math.BigDecimal + +data class FeeConfig( + val name: String, + val displayOrder: Int, + val minAssetVolume: BigDecimal, + val maxAssetVolume: BigDecimal? = null, + val minTradeVolume: BigDecimal, + val maxTradeVolume: BigDecimal? = null, + val makerFee: BigDecimal, + val takerFee: BigDecimal, + val condition: Condition +) +enum class Condition { AND, OR } \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/KycLevel.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/KycLevel.kt index dc73dfc2b..c68bb84ed 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/KycLevel.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/KycLevel.kt @@ -1,6 +1,6 @@ package co.nilin.opex.accountant.core.model enum class KycLevel { - Level1, Level2, + LEVEL_1, LEVEL_2,LEVEL_3 } diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/PairFeeConfig.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/PairFeeConfig.kt deleted file mode 100644 index 052e6f2f8..000000000 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/PairFeeConfig.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.nilin.opex.accountant.core.model - -import java.math.BigDecimal - -class PairFeeConfig( - val pairConfig: PairConfig, - val direction: String, - val userLevel: String, - val makerFee: BigDecimal, - val takerFee: BigDecimal -) \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/TotalAssetsSnapshot.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/TotalAssetsSnapshot.kt new file mode 100644 index 000000000..8867165f9 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/TotalAssetsSnapshot.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.accountant.core.model + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class TotalAssetsSnapshot( + val uuid: String, + val totalAmount: BigDecimal, + val quoteCurrency: String, + val snapshotDate: LocalDateTime, +) \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/UserFee.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/UserFee.kt new file mode 100644 index 000000000..f6c0c7e7b --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/UserFee.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.accountant.core.model + +import java.math.BigDecimal + +data class UserFee( + val name: String, + val makerFee: BigDecimal, + val takerFee: BigDecimal, +) diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/WithdrawLimitConfig.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/WithdrawLimitConfig.kt new file mode 100644 index 000000000..27ec36230 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/WithdrawLimitConfig.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.accountant.core.model + +import java.math.BigDecimal + +data class WithdrawLimitConfig( + val name: String, + val dailyMaxAmount: BigDecimal, +) diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/WithdrawStatus.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/WithdrawStatus.kt new file mode 100644 index 000000000..f8ebdaf2c --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/model/WithdrawStatus.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.accountant.core.model + +enum class WithdrawStatus { + CREATED, + PROCESSING, + CANCELED, + REJECTED, + DONE +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/DepositActivityManagerImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/DepositActivityManagerImpl.kt new file mode 100644 index 000000000..22c5fa720 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/DepositActivityManagerImpl.kt @@ -0,0 +1,73 @@ +package co.nilin.opex.accountant.core.service + +import co.nilin.opex.accountant.core.api.DepositActivityManager +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.spi.UserDepositVolumePersister +import co.nilin.opex.common.utils.CacheManager +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors + +@Service +class DepositActivityManagerImpl( + private val cacheManager: CacheManager, + private val depositVolumePersister: UserDepositVolumePersister, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, +) : DepositActivityManager { + override suspend fun getLastDaysDepositActivity( + userId: String, + quoteCurrency: String?, + n: Int + ): List { + val today = LocalDate.now(ZoneOffset.of(zoneOffsetString)) + val dates = (0..n - 1).map { today.minusDays(it.toLong()) } + + val result = mutableMapOf() + val missingDates = mutableListOf() + + for (date in dates) { + val cacheKey = "deposit:daily:$userId:$date" + val cached = cacheManager.get(cacheKey) + + if (cached != null) { + result[date] = cached + } else { + missingDates.add(date) + } + } + + if (missingDates.isNotEmpty()) { + val startDate = missingDates.minOrNull()!! + + val dbData = depositVolumePersister.getLastDaysDeposit(userId, startDate, quoteCurrency) + .stream().collect(Collectors.toMap(DailyAmount::date, DailyAmount::totalAmount)); + + for (date in missingDates) { + val value = dbData[date] ?: BigDecimal.ZERO + val (ttl, unit) = ttlFor(date, today) + val cacheKey = "deposit:daily:$userId:$date" + + cacheManager.put(cacheKey, value, ttl, unit) + result[date] = value + } + } + + return result + .map { DailyAmount(it.key, it.value) } + .sortedBy { it.date } + + } + + + private fun ttlFor(date: LocalDate, today: LocalDate): Pair = + if (date == today) { + 15L to TimeUnit.MINUTES + } else { + 100L to TimeUnit.DAYS + } + +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImpl.kt index 7e22d75ab..789cc388f 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImpl.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImpl.kt @@ -2,21 +2,59 @@ package co.nilin.opex.accountant.core.service import co.nilin.opex.accountant.core.api.FeeCalculator import co.nilin.opex.accountant.core.model.* +import co.nilin.opex.accountant.core.spi.FeeConfigService import co.nilin.opex.accountant.core.spi.JsonMapper +import co.nilin.opex.accountant.core.spi.UserTradeVolumePersister +import co.nilin.opex.accountant.core.spi.WalletProxy +import co.nilin.opex.common.utils.CacheManager import co.nilin.opex.matching.engine.core.eventh.events.TradeEvent import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit @Component class FeeCalculatorImpl( + private val walletProxy: WalletProxy, + private val feeConfigService: FeeConfigService, + private val userVolumePersister: UserTradeVolumePersister, + @Qualifier("appCacheManager") private val cacheManager: CacheManager, @Value("\${app.address}") private val platformAddress: String, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, + @Value("\${app.trade-volume-calculation-currency}") + private val tradeVolumeCalculationCurrency: String, private val jsonMapper: JsonMapper ) : FeeCalculator { private val logger = LoggerFactory.getLogger(FeeCalculatorImpl::class.java) + override suspend fun getUserFee(uuid: String): UserFee { + val cached = cacheManager.get(uuid) + if (cached != null) { + return cached + } + + val totalAssets = walletProxy.getUserTotalAssets(uuid) + val userVolumeData = userVolumePersister.getUserTotalTradeVolume( + uuid, + LocalDateTime.now().atOffset(ZoneOffset.of(zoneOffsetString)).toLocalDate().minusMonths(1L), + tradeVolumeCalculationCurrency + ) + + val feeConfig = feeConfigService.loadMatchingFeeConfig( + totalAssets?.totalAmount ?: BigDecimal.ZERO, + userVolumeData ?: BigDecimal.ZERO + ) + val remainingMillis = remainingMillisUntil1AM() + cacheManager.put(uuid, feeConfig, remainingMillis, TimeUnit.MILLISECONDS) + return feeConfig + } + override suspend fun createFeeActions( trade: TradeEvent, makerOrder: Order, @@ -84,4 +122,12 @@ class FeeCalculatorImpl( val eventMap: Map = jsonMapper.toMap(tradeEvent) return orderMap + eventMap } + + private fun remainingMillisUntil1AM(): Long { + val now = LocalDateTime.now().atOffset(ZoneOffset.of(zoneOffsetString)) + val target = + LocalDateTime.now().atOffset(ZoneOffset.of(zoneOffsetString)).plusDays(1).withHour(1) + val remainingMillis = Duration.between(now, target).toMillis() + return if (remainingMillis > 0) remainingMillis else Duration.ofHours(1).toMillis() + } } \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FinancialActionJobManagerImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FinancialActionJobManagerImpl.kt index f6c25d076..0dce40930 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FinancialActionJobManagerImpl.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/FinancialActionJobManagerImpl.kt @@ -3,7 +3,9 @@ package co.nilin.opex.accountant.core.service import co.nilin.opex.accountant.core.api.FinancialActionJobManager import co.nilin.opex.accountant.core.model.FinancialAction import co.nilin.opex.accountant.core.model.FinancialActionStatus -import co.nilin.opex.accountant.core.spi.* +import co.nilin.opex.accountant.core.spi.FinancialActionLoader +import co.nilin.opex.accountant.core.spi.FinancialActionPersister +import co.nilin.opex.accountant.core.spi.WalletProxy import co.nilin.opex.utility.error.data.OpexException import org.slf4j.LoggerFactory import org.springframework.web.reactive.function.client.WebClientResponseException diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt index d4bfe0b42..34bf51f4b 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt @@ -1,5 +1,6 @@ package co.nilin.opex.accountant.core.service +import co.nilin.opex.accountant.core.api.FeeCalculator import co.nilin.opex.accountant.core.api.OrderManager import co.nilin.opex.accountant.core.inout.OrderStatus import co.nilin.opex.accountant.core.inout.RichOrder @@ -15,14 +16,13 @@ import java.time.LocalDateTime open class OrderManagerImpl( private val pairConfigLoader: PairConfigLoader, - private val userLevelLoader: UserLevelLoader, private val financialActionPersister: FinancialActionPersister, private val financeActionLoader: FinancialActionLoader, private val orderPersister: OrderPersister, private val tempEventPersister: TempEventPersister, private val richOrderPublisher: RichOrderPublisher, private val financialActionPublisher: FinancialActionPublisher, - private val jsonMapper: JsonMapper, + private val feeCalculator: FeeCalculator ) : OrderManager { @Transactional @@ -36,14 +36,13 @@ open class OrderManagerImpl( submitOrderEvent.pair.rightSideName } - val level = userLevelLoader.load(submitOrderEvent.uuid) - val pairFeeConfig = pairConfigLoader.load( + val pairConfig = pairConfigLoader.load( submitOrderEvent.pair.toString(), submitOrderEvent.direction, - level ) - val makerFee = pairFeeConfig.makerFee * BigDecimal.ONE //user level formula - val takerFee = pairFeeConfig.takerFee * BigDecimal.ONE //user level formula + val fee = feeCalculator.getUserFee(submitOrderEvent.uuid) + val makerFee = fee.makerFee + val takerFee = fee.takerFee //create fa for transfer uuid symbol main wallet to uuid symbol exchange wallet /* @@ -52,11 +51,11 @@ open class OrderManagerImpl( */ val amount = if (submitOrderEvent.direction == OrderDirection.ASK) { - BigDecimal(submitOrderEvent.quantity).multiply(pairFeeConfig.pairConfig.leftSideFraction) + BigDecimal(submitOrderEvent.quantity).multiply(pairConfig.leftSideFraction) } else { - BigDecimal(submitOrderEvent.quantity).multiply(pairFeeConfig.pairConfig.leftSideFraction) + BigDecimal(submitOrderEvent.quantity).multiply(pairConfig.leftSideFraction) .multiply(submitOrderEvent.price.toBigDecimal()) - .multiply(pairFeeConfig.pairConfig.rightSideFraction) + .multiply(pairConfig.rightSideFraction) } //store order (ouid, uuid, fees, userlevel, pair, direction, price, quantity, filledQ, status, transfered) @@ -67,8 +66,8 @@ open class OrderManagerImpl( null, makerFee, takerFee, - pairFeeConfig.pairConfig.leftSideFraction, - pairFeeConfig.pairConfig.rightSideFraction, + pairConfig.leftSideFraction, + pairConfig.rightSideFraction, submitOrderEvent.uuid, submitOrderEvent.userLevel, submitOrderEvent.direction, @@ -78,10 +77,10 @@ open class OrderManagerImpl( submitOrderEvent.quantity, submitOrderEvent.quantity - submitOrderEvent.remainedQuantity, submitOrderEvent.price.toBigDecimal() - .multiply(pairFeeConfig.pairConfig.rightSideFraction), + .multiply(pairConfig.rightSideFraction), submitOrderEvent.quantity.toBigDecimal() - .multiply(pairFeeConfig.pairConfig.leftSideFraction), - BigDecimal(submitOrderEvent.quantity - submitOrderEvent.remainedQuantity).multiply(pairFeeConfig.pairConfig.leftSideFraction), + .multiply(pairConfig.leftSideFraction), + BigDecimal(submitOrderEvent.quantity - submitOrderEvent.remainedQuantity).multiply(pairConfig.leftSideFraction), amount, amount, OrderStatus.REQUESTED.code @@ -269,22 +268,10 @@ open class OrderManagerImpl( } } - private fun createMap(rejectOrderEvent: RejectOrderEvent, order: Order): Map { - val orderMap: Map = jsonMapper.toMap(order) - val eventMap: Map = jsonMapper.toMap(rejectOrderEvent) - return orderMap + eventMap - } + private suspend fun getFee(uuid: String): BigDecimal { - private fun createMap(cancelOrderEvent: CancelOrderEvent, order: Order): Map { - val orderMap: Map = jsonMapper.toMap(order) - val eventMap: Map = jsonMapper.toMap(cancelOrderEvent) - return orderMap + eventMap - } - private fun createMap(submitOrderEvent: SubmitOrderEvent, order: Order): Map { - val orderMap: Map = jsonMapper.toMap(order) - val eventMap: Map = jsonMapper.toMap(submitOrderEvent) - return orderMap + eventMap + return BigDecimal.ONE } } \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/TradeActivityManagerImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/TradeActivityManagerImpl.kt new file mode 100644 index 000000000..51ed4dbd8 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/TradeActivityManagerImpl.kt @@ -0,0 +1,73 @@ +package co.nilin.opex.accountant.core.service + +import co.nilin.opex.accountant.core.api.TradeActivityManager +import co.nilin.opex.accountant.core.api.WithdrawActivityManager +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.spi.UserTradeVolumePersister +import co.nilin.opex.common.utils.CacheManager +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors +@Service +class TradeActivityManagerImpl( + private val cacheManager: CacheManager, + private val tradeVolumePersister: UserTradeVolumePersister, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, +) : TradeActivityManager { + override suspend fun getLastDaysTradeActivity( + userId: String, + quoteCurrency: String?, + n: Int + ): List { + val today = LocalDate.now(ZoneOffset.of(zoneOffsetString)) + val dates = (0..n - 1).map { today.minusDays(it.toLong()) } + + val result = mutableMapOf() + val missingDates = mutableListOf() + + for (date in dates) { + val cacheKey = "trade:daily:$userId:$date" + val cached = cacheManager.get(cacheKey) + + if (cached != null) { + result[date] = cached + } else { + missingDates.add(date) + } + } + + if (missingDates.isNotEmpty()) { + val startDate = missingDates.minOrNull()!! + + val dbData = tradeVolumePersister.getLastDaysTrade(userId, startDate,quoteCurrency) + .stream().collect(Collectors.toMap(DailyAmount::date, DailyAmount::totalAmount)); + + for (date in missingDates) { + val value = dbData[date] ?: BigDecimal.ZERO + val (ttl, unit) = ttlFor(date, today) + val cacheKey = "trade:daily:$userId:$date" + + cacheManager.put(cacheKey, value, ttl, unit) + result[date] = value + } + } + + return result + .map { DailyAmount(it.key, it.value) } + .sortedBy { it.date } + + } + + + private fun ttlFor(date: LocalDate, today: LocalDate): Pair = + if (date == today) { + 15L to TimeUnit.MINUTES + } else { + 100L to TimeUnit.DAYS + } + +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImpl.kt index b6c574ea3..f6ce24662 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImpl.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImpl.kt @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.time.LocalDateTime +import java.time.ZoneOffset open class TradeManagerImpl( private val financeActionPersister: FinancialActionPersister, @@ -22,7 +23,10 @@ open class TradeManagerImpl( private val richOrderPublisher: RichOrderPublisher, private val feeCalculator: FeeCalculator, private val financialActionPublisher: FinancialActionPublisher, - private val jsonMapper: JsonMapper + private val currencyRatePersister: CurrencyRatePersister, + private val userVolumePersister: UserTradeVolumePersister, + private val tradeVolumeCalculationCurrency: String, + private val zoneOffsetString: String, ) : TradeManager { private val logger = LoggerFactory.getLogger(TradeManagerImpl::class.java) @@ -154,39 +158,71 @@ open class TradeManagerImpl( val takerPrice = trade.takerPrice.toBigDecimal().multiply(takerOrder.rightSideFraction) val makerPrice = trade.makerPrice.toBigDecimal().multiply(makerOrder.rightSideFraction) - richTradePublisher.publish( - RichTrade( - trade.tradeId, - trade.pair.toString(), - trade.takerOuid, - trade.takerUuid, - trade.takerOrderId, - trade.takerDirection, - takerPrice, - takerOrder.origQuantity, - takerOrder.origPrice.multiply(takerOrder.origQuantity), - trade.takerRemainedQuantity.toBigDecimal().multiply(takerOrder.leftSideFraction), - feeActions.takerFeeAction.amount, - feeActions.takerFeeAction.symbol, - trade.makerOuid, - trade.makerUuid, - trade.makerOrderId, - trade.makerDirection, - makerPrice, - makerOrder.origQuantity, - makerOrder.origPrice.multiply(makerOrder.origQuantity), - trade.makerRemainedQuantity.toBigDecimal().multiply(makerOrder.leftSideFraction), - feeActions.makerFeeAction.amount, - feeActions.makerFeeAction.symbol, - makerPrice, - trade.matchedQuantity.toBigDecimal().multiply(makerOrder.leftSideFraction), - trade.eventDate - ) + val richTrade = RichTrade( + trade.tradeId, + trade.pair.toString(), + trade.takerOuid, + trade.takerUuid, + trade.takerOrderId, + trade.takerDirection, + takerPrice, + takerOrder.origQuantity, + takerOrder.origPrice.multiply(takerOrder.origQuantity), + trade.takerRemainedQuantity.toBigDecimal().multiply(takerOrder.leftSideFraction), + feeActions.takerFeeAction.amount, + feeActions.takerFeeAction.symbol, + trade.makerOuid, + trade.makerUuid, + trade.makerOrderId, + trade.makerDirection, + makerPrice, + makerOrder.origQuantity, + makerOrder.origPrice.multiply(makerOrder.origQuantity), + trade.makerRemainedQuantity.toBigDecimal().multiply(makerOrder.leftSideFraction), + feeActions.makerFeeAction.amount, + feeActions.makerFeeAction.symbol, + makerPrice, + trade.matchedQuantity.toBigDecimal().multiply(makerOrder.leftSideFraction), + trade.eventDate ) + richTradePublisher.publish(richTrade) + + currencyRatePersister.updateRate(trade.pair.leftSideName, trade.pair.rightSideName, makerPrice) + calculateTradeVolume(richTrade, trade.pair.leftSideName, trade.pair.rightSideName) + return financeActionPersister.persist(financialActions) //return financeActionPersister.persist(financialActions).also { publishFinancialActions(it) } } + private suspend fun calculateTradeVolume(trade: RichTrade, base: String, quote: String) { + val today = LocalDateTime.now().atOffset(ZoneOffset.of(zoneOffsetString)).toLocalDate() + + val rate = if (quote == tradeVolumeCalculationCurrency) { + currencyRatePersister.getRate(base, quote) + } else { + currencyRatePersister.getRate(quote, tradeVolumeCalculationCurrency) + } + val totalAmount = trade.matchedQuantity.multiply(rate) + + userVolumePersister.update( + trade.makerUuid, + base, + today, + trade.matchedQuantity, + totalAmount, + tradeVolumeCalculationCurrency + ) + userVolumePersister.update( + trade.takerUuid, + base, + today, + trade.matchedQuantity, + totalAmount, + tradeVolumeCalculationCurrency + ) + logger.info("Trade volume updated") + } + private fun createFinalizeOrderFinancialAction( parentTransferAction: FinancialAction?, order: Order, @@ -250,10 +286,4 @@ open class TradeManagerImpl( if (!list.contains(financialAction)) list.add(financialAction) } - - private fun createMap(tradeEvent: TradeEvent, order: Order): Map { - val orderMap: Map = jsonMapper.toMap(order) - val eventMap: Map = jsonMapper.toMap(tradeEvent) - return orderMap + eventMap - } } \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/WithdrawActivityManagerImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/WithdrawActivityManagerImpl.kt new file mode 100644 index 000000000..cbf8dd491 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/WithdrawActivityManagerImpl.kt @@ -0,0 +1,72 @@ +package co.nilin.opex.accountant.core.service + +import co.nilin.opex.accountant.core.api.WithdrawActivityManager +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.spi.UserWithdrawVolumePersister +import co.nilin.opex.common.utils.CacheManager +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors +@Service +class WithdrawActivityManagerImpl( + private val cacheManager: CacheManager, + private val withdrawVolumePersister: UserWithdrawVolumePersister, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, +) : WithdrawActivityManager { + override suspend fun getLastDaysWithdrawActivity( + userId: String, + quoteCurrency: String?, + n: Int + ): List { + val today = LocalDate.now(ZoneOffset.of(zoneOffsetString)) + val dates = (0..n - 1).map { today.minusDays(it.toLong()) } + + val result = mutableMapOf() + val missingDates = mutableListOf() + + for (date in dates) { + val cacheKey = "withdraw:daily:$userId:$date" + val cached = cacheManager.get(cacheKey) + + if (cached != null) { + result[date] = cached + } else { + missingDates.add(date) + } + } + + if (missingDates.isNotEmpty()) { + val startDate = missingDates.minOrNull()!! + + val dbData = withdrawVolumePersister.getLastDaysWithdraw(userId, startDate,quoteCurrency) + .stream().collect(Collectors.toMap(DailyAmount::date, DailyAmount::totalAmount)); + + for (date in missingDates) { + val value = dbData[date] ?: BigDecimal.ZERO + val (ttl, unit) = ttlFor(date, today) + val cacheKey = "withdraw:daily:$userId:$date" + + cacheManager.put(cacheKey, value, ttl, unit) + result[date] = value + } + } + + return result + .map { DailyAmount(it.key, it.value) } + .sortedBy { it.date } + + } + + + private fun ttlFor(date: LocalDate, today: LocalDate): Pair = + if (date == today) { + 15L to TimeUnit.MINUTES + } else { + 100L to TimeUnit.DAYS + } + +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/CurrencyRatePersister.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/CurrencyRatePersister.kt new file mode 100644 index 000000000..96ded5fde --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/CurrencyRatePersister.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.accountant.core.spi + +import java.math.BigDecimal + +interface CurrencyRatePersister { + + suspend fun updateRate(base: String, quote: String, rate: BigDecimal) + + suspend fun getRate(base: String, quote: String): BigDecimal +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/FeeConfigService.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/FeeConfigService.kt new file mode 100644 index 000000000..e258cd7c9 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/FeeConfigService.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.accountant.core.spi + +import co.nilin.opex.accountant.core.model.FeeConfig +import co.nilin.opex.accountant.core.model.UserFee +import java.math.BigDecimal + +interface FeeConfigService { + + suspend fun loadFeeConfigs(): List + suspend fun saveFee(config: FeeConfig) + suspend fun updateFee(config: FeeConfig) + suspend fun loadFeeConfig(name: String): FeeConfig + suspend fun loadMatchingFeeConfig(assetVolume: BigDecimal, tradeVolume: BigDecimal): UserFee + +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/PairConfigLoader.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/PairConfigLoader.kt index 800a8fe4b..a6a1d1c67 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/PairConfigLoader.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/PairConfigLoader.kt @@ -1,20 +1,11 @@ package co.nilin.opex.accountant.core.spi import co.nilin.opex.accountant.core.model.PairConfig -import co.nilin.opex.accountant.core.model.PairFeeConfig import co.nilin.opex.matching.engine.core.model.OrderDirection interface PairConfigLoader { suspend fun loadPairConfigs(): List - suspend fun loadPairFeeConfigs(): List - - suspend fun loadPairFeeConfigs(direction: OrderDirection, userLevel: String): List - - suspend fun loadPairFeeConfigs(pair: String, direction: OrderDirection, userLevel: String): PairFeeConfig? - - suspend fun load(pair: String, direction: OrderDirection, userLevel: String): PairFeeConfig - suspend fun load(pair: String, direction: OrderDirection): PairConfig } \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserDepositVolumePersister.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserDepositVolumePersister.kt new file mode 100644 index 000000000..496ccf3a4 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserDepositVolumePersister.kt @@ -0,0 +1,20 @@ +package co.nilin.opex.accountant.core.spi + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.model.WithdrawStatus +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime + +interface UserDepositVolumePersister { + suspend fun update( + userId: String, + currency: String, + amount: BigDecimal, + date: LocalDateTime) + + suspend fun getTotalValueByUserAndDateAfter(uuid: String, startDate: LocalDateTime): BigDecimal + + suspend fun getLastDaysDeposit(userId: String, startDate: LocalDate?, quatCurrency: String?, lastDays: Long = 31): List + +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserLevelLoader.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserLevelLoader.kt deleted file mode 100644 index b1e2c3173..000000000 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserLevelLoader.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.nilin.opex.accountant.core.spi - -import co.nilin.opex.accountant.core.model.KycLevel - -interface UserLevelLoader { - - suspend fun load(uuid: String): String - - suspend fun update(uuid: String,userLevel:KycLevel) - -} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserTradeVolumePersister.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserTradeVolumePersister.kt new file mode 100644 index 000000000..5e6de6bbe --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserTradeVolumePersister.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.accountant.core.spi + +import co.nilin.opex.accountant.core.model.DailyAmount +import java.math.BigDecimal +import java.time.LocalDate + +interface UserTradeVolumePersister { + + suspend fun update( + userId: String, + currency: String, + date: LocalDate, + volume: BigDecimal, + totalAmount: BigDecimal, + quoteCurrency: String + ) + + suspend fun getUserTotalTradeVolume(uuid: String, startDate: LocalDate, quoteCurrency: String): BigDecimal? + suspend fun getUserTotalTradeVolumeByCurrency( + uuid: String, + currency: String, + startDate: LocalDate, + quoteCurrency: String + ): BigDecimal? + suspend fun getLastDaysTrade(userId: String, startDate: LocalDate?, quatCurrency: String?, lastDays: Long = 31): List + +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserWithdrawVolumePersister.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserWithdrawVolumePersister.kt new file mode 100644 index 000000000..7bc056c49 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserWithdrawVolumePersister.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.accountant.core.spi + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.model.WithdrawStatus +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime + +interface UserWithdrawVolumePersister { + suspend fun update( + userId: String, currency: String, amount: BigDecimal, date: LocalDateTime, withdrawStatus: WithdrawStatus + ) + + suspend fun getTotalValueByUserAndDateAfter(uuid: String, startDate: LocalDateTime): BigDecimal + + suspend fun getLastDaysWithdraw(userId: String, startDate: LocalDate?, quatCurrency: String?, lastDays: Long = 31): List +} \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/WalletProxy.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/WalletProxy.kt index 59bdce3fe..78575b25d 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/WalletProxy.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/WalletProxy.kt @@ -1,5 +1,7 @@ package co.nilin.opex.accountant.core.spi +import co.nilin.opex.accountant.core.model.CurrencyPrice +import co.nilin.opex.accountant.core.model.TotalAssetsSnapshot import co.nilin.opex.accountant.core.model.WalletType import java.math.BigDecimal @@ -18,4 +20,10 @@ interface WalletProxy { ) suspend fun canFulfil(symbol: String, walletType: WalletType, uuid: String, amount: BigDecimal): Boolean + + suspend fun getUserTotalAssets( + uuid: String + ): TotalAssetsSnapshot? + + suspend fun getPrices(quote : String) : List } \ No newline at end of file diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/WithdrawLimitManager.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/WithdrawLimitManager.kt new file mode 100644 index 000000000..be40bf526 --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/WithdrawLimitManager.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.accountant.core.spi + +import co.nilin.opex.accountant.core.model.WithdrawLimitConfig +import java.math.BigDecimal + +interface WithdrawLimitManager { + suspend fun canWithdraw(uuid: String, userLevel: String, currency: String, amount: BigDecimal): Boolean + suspend fun getAll(): List +} \ No newline at end of file diff --git a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImplTest.kt b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImplTest.kt index 8add7dac4..55e69d80d 100644 --- a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImplTest.kt +++ b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/FeeCalculatorImplTest.kt @@ -2,16 +2,40 @@ package co.nilin.opex.accountant.core.service import co.nilin.opex.accountant.core.model.FinancialAction import co.nilin.opex.accountant.core.model.FinancialActionCategory +import co.nilin.opex.accountant.core.model.UserFee import co.nilin.opex.accountant.core.model.WalletType +import co.nilin.opex.accountant.core.spi.FeeConfigService +import co.nilin.opex.accountant.core.spi.UserTradeVolumePersister +import co.nilin.opex.accountant.core.spi.WalletProxy +import co.nilin.opex.common.utils.CacheManager import co.nilin.opex.matching.engine.core.eventh.events.TradeEvent +import io.mockk.* import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Qualifier +import java.math.BigDecimal +import java.util.concurrent.TimeUnit + internal class FeeCalculatorImplTest { + private val walletProxy = mockk() + private val feeConfigService = mockk() + private val userVolumePersister = mockk() + @Qualifier("appCacheManager") private val cacheManager = mockk>() + private val receiverAddress = "0x0" - private val feeCalculator = FeeCalculatorImpl(receiverAddress, JsonMapperTestImpl()) + private val feeCalculator = FeeCalculatorImpl( + walletProxy, + feeConfigService, + userVolumePersister, + cacheManager, + receiverAddress, + "+03:30", + "USDT", + JsonMapperTestImpl(), + ) @Test fun givenTradeEventsAndOrders_whenFeeCalculated_feeActionsNotNull(): Unit = runBlocking { @@ -71,4 +95,70 @@ internal class FeeCalculatorImplTest { assertThat(actions.takerFeeAction.parent).isNotNull } + @Test + fun givenCachedValueExists_whenGetUserFeeCalled_thenReturnCachedValueAndSkipServices() { + runBlocking { + val uuid = "user_1" + val cachedFee = UserFee("test1", BigDecimal("0.1"), BigDecimal("0.1")) + + every { cacheManager.get(uuid) } returns cachedFee + + val result = feeCalculator.getUserFee(uuid) + + assertThat(result).isEqualTo(cachedFee) + verify(exactly = 1) { cacheManager.get(uuid) } + verify { walletProxy wasNot Called } + verify { userVolumePersister wasNot Called } + verify { feeConfigService wasNot Called } + } + } + + @Test + fun givenNoCachedValue_whenGetUserFee_thenFetchFromServicesAndCacheIt() = runBlocking { + val uuid = "user_2" + val expectedFee = UserFee("test2", BigDecimal("0.2"), BigDecimal("0.2")) + + every { cacheManager.get(uuid) } returns null + coEvery { walletProxy.getUserTotalAssets(uuid) } returns mockk { + every { totalAmount } returns BigDecimal("1000") + } + coEvery { userVolumePersister.getUserTotalTradeVolume(any(), any(), any()) } returns BigDecimal("200") + coEvery { feeConfigService.loadMatchingFeeConfig(any(), any()) } returns expectedFee + every { cacheManager.put(eq(uuid), eq(expectedFee), any(), eq(TimeUnit.MILLISECONDS)) } just Runs + + val result = feeCalculator.getUserFee(uuid) + + assertThat(result).isEqualTo(expectedFee) + coVerifyOrder { + cacheManager.get(uuid) + walletProxy.getUserTotalAssets(uuid) + } + coVerifyOrder { + userVolumePersister.getUserTotalTradeVolume(any(), any(), any()) + } + coVerifyOrder { + feeConfigService.loadMatchingFeeConfig(BigDecimal("1000"), BigDecimal("200")) + cacheManager.put(uuid, expectedFee, any(), TimeUnit.MILLISECONDS) + } + } + + @Test + fun givenAssetsAndVolume_whenGetUserFee_thenPassCorrectValuesToFeeConfigService() = runBlocking { + val uuid = "user_3" + val assets = BigDecimal("5000") + val volume = BigDecimal("300") + val expectedFee = UserFee("test", BigDecimal("0.1"), BigDecimal("0.1")) + + every { cacheManager.get(uuid) } returns null + coEvery { walletProxy.getUserTotalAssets(uuid) } returns mockk { every { totalAmount } returns assets } + coEvery { userVolumePersister.getUserTotalTradeVolume(eq(uuid), any(), eq("USDT")) } returns volume + coEvery { feeConfigService.loadMatchingFeeConfig(assets, volume) } returns expectedFee + every { cacheManager.put(eq(uuid), eq(expectedFee), any(), eq(TimeUnit.MILLISECONDS)) } just Runs + + val result = feeCalculator.getUserFee(uuid) + + assertThat(result).isEqualTo(expectedFee) + coVerify { feeConfigService.loadMatchingFeeConfig(assets, volume) } + } + } \ No newline at end of file diff --git a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt index 24d1c05d6..6147be7e2 100644 --- a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt +++ b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt @@ -1,5 +1,6 @@ package co.nilin.opex.accountant.core.service +import co.nilin.opex.accountant.core.api.FeeCalculator import co.nilin.opex.accountant.core.inout.OrderStatus import co.nilin.opex.accountant.core.model.* import co.nilin.opex.accountant.core.spi.* @@ -30,19 +31,18 @@ internal class OrderManagerImplTest { private val tempEventPersister = mockk() private val pairConfigLoader = mockk() private val richOrderPublisher = mockk() - private val userLevelLoader = mockk() private val financialActionPublisher = mockk() + private val feeCalculator = mockk() private val orderManager = OrderManagerImpl( pairConfigLoader, - userLevelLoader, financialActionPersister, financialActionLoader, orderPersister, tempEventPersister, richOrderPublisher, financialActionPublisher, - JsonMapperTestImpl() + feeCalculator ) init { @@ -54,7 +54,6 @@ internal class OrderManagerImplTest { coEvery { financialActionPersister.persist(any()) } returnsArgument (0) coEvery { financialActionPersister.updateStatus(any(), any()) } returns Unit coEvery { financialActionPersister.updateStatus(any(), any()) } returns Unit - coEvery { userLevelLoader.load(any()) } returns "*" coEvery { financialActionPublisher.publish(any()) } returns Unit } @@ -73,18 +72,17 @@ internal class OrderManagerImplTest { "ouid", "uuid", null, pair, 30, 60, 0, OrderDirection.ASK, MatchConstraint.GTC, OrderType.LIMIT_ORDER ) - coEvery { - pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, any()) - } returns PairFeeConfig( - pairConfig, - submitOrderEvent.direction.toString(), - "", - BigDecimal.valueOf(0.1), - BigDecimal.valueOf(0.12) - ) - coEvery { financialActionPersister.persist(any()) } returnsArgument (0) + coEvery { + pairConfigLoader.load(pair.toString(), submitOrderEvent.direction) + } returns pairConfig + + coEvery { + feeCalculator.getUserFee(submitOrderEvent.uuid) + } returns UserFee( + "Test", BigDecimal.valueOf(0.08), BigDecimal.valueOf(0.1) + ) //when val financialActions = orderManager.handleRequestOrder(submitOrderEvent) @@ -130,17 +128,17 @@ internal class OrderManagerImplTest { "ouid", "uuid", null, pair, 35, 14, 0, OrderDirection.BID, MatchConstraint.GTC, OrderType.LIMIT_ORDER ) + coEvery { financialActionPersister.persist(any()) } returnsArgument (0) + coEvery { - pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, any()) - } returns PairFeeConfig( - pairConfig, - submitOrderEvent.direction.toString(), - "", - BigDecimal.valueOf(0.08), - BigDecimal.valueOf(0.1) - ) + pairConfigLoader.load(pair.toString(), submitOrderEvent.direction) + } returns pairConfig - coEvery { financialActionPersister.persist(any()) } returnsArgument (0) + coEvery { + feeCalculator.getUserFee(submitOrderEvent.uuid) + } returns UserFee( + "Test", BigDecimal.valueOf(0.08), BigDecimal.valueOf(0.1) + ) //when val financialActions = orderManager.handleRequestOrder(submitOrderEvent) diff --git a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt index ac5045208..455204684 100644 --- a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt +++ b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt @@ -1,7 +1,9 @@ package co.nilin.opex.accountant.core.service +import co.nilin.opex.accountant.core.api.FeeCalculator import co.nilin.opex.accountant.core.model.* import co.nilin.opex.accountant.core.spi.* +import co.nilin.opex.common.utils.CacheManager import co.nilin.opex.matching.engine.core.eventh.events.SubmitOrderEvent import co.nilin.opex.matching.engine.core.eventh.events.TradeEvent import co.nilin.opex.matching.engine.core.model.MatchConstraint @@ -9,7 +11,9 @@ import co.nilin.opex.matching.engine.core.model.OrderDirection import co.nilin.opex.matching.engine.core.model.OrderType import co.nilin.opex.matching.engine.core.model.Pair import io.mockk.coEvery +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -24,20 +28,25 @@ internal class TradeManagerImplTest { private val tempEventPersister = mockk() private val richOrderPublisher = mockk() private val richTradePublisher = mockk() - private val userLevelLoader = mockk() private val financialActionPublisher = mockk() + private val currencyRatePersister = mockk() + private val userVolumePersister = mockk() + private val feeCalculator = mockk() + private val walletProxy = mockk() + private val feeConfigService = mockk() + private val cacheManager = mockk>() + private val jsonMapper = JsonMapperTestImpl() private val orderManager = OrderManagerImpl( pairConfigLoader, - userLevelLoader, financialActionPersister, financeActionLoader, orderPersister, tempEventPersister, richOrderPublisher, financialActionPublisher, - jsonMapper + feeCalculator ) private val tradeManager = TradeManagerImpl( @@ -47,9 +56,21 @@ internal class TradeManagerImplTest { tempEventPersister, richTradePublisher, richOrderPublisher, - FeeCalculatorImpl("0x0", jsonMapper), + FeeCalculatorImpl( + walletProxy, + feeConfigService, + userVolumePersister, + cacheManager, + "0x0", + "+03:30", + "USDT", + JsonMapperTestImpl() + ), financialActionPublisher, - jsonMapper + currencyRatePersister, + userVolumePersister, + "USDT", + "+03:30", ) init { @@ -58,10 +79,12 @@ internal class TradeManagerImplTest { coEvery { financeActionLoader.findLast(any(), any()) } returns null coEvery { richOrderPublisher.publish(any()) } returns Unit coEvery { richTradePublisher.publish(any()) } returns Unit - coEvery { userLevelLoader.load(any()) } returns "*" coEvery { financialActionPublisher.publish(any()) } returns Unit coEvery { financialActionPersister.updateStatus(any(), any()) } returns Unit coEvery { financialActionPersister.updateStatus(any(), any()) } returns Unit + coEvery { currencyRatePersister.updateRate(any(), any(), any()) } just runs + coEvery { userVolumePersister.update(any(), any(), any(), any(), any(), any()) } just runs + coEvery { currencyRatePersister.getRate(any(), any()) } returns BigDecimal.ONE } @Test @@ -309,22 +332,23 @@ internal class TradeManagerImplTest { takerFee: BigDecimal ) { coEvery { - pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, any()) - } returns PairFeeConfig( - pairConfig, - submitOrderEvent.direction.toString(), - "", - makerFee, - takerFee + feeCalculator.getUserFee(submitOrderEvent.uuid) + } returns UserFee( + "Test", makerFee, takerFee ) + + coEvery { + pairConfigLoader.load(pair.toString(), submitOrderEvent.direction) + } returns pairConfig + coEvery { financialActionPersister.persist(any()) } returnsArgument (0) val financialActions = orderManager.handleRequestOrder(submitOrderEvent) - val orderPairFeeConfig = - pairConfigLoader.load(submitOrderEvent.pair.toString(), submitOrderEvent.direction, "") - val orderMakerFee = orderPairFeeConfig.makerFee * BigDecimal.ONE //user level formula - val orderTakerFee = orderPairFeeConfig.takerFee * BigDecimal.ONE //user level formula + val pairConfig = + pairConfigLoader.load(submitOrderEvent.pair.toString(), submitOrderEvent.direction) + val orderMakerFee = makerFee + val orderTakerFee = takerFee coEvery { orderPersister.load(submitOrderEvent.ouid) } returns Order( submitOrderEvent.pair.toString(), @@ -332,8 +356,8 @@ internal class TradeManagerImplTest { null, orderMakerFee, orderTakerFee, - orderPairFeeConfig.pairConfig.leftSideFraction, - orderPairFeeConfig.pairConfig.rightSideFraction, + pairConfig.leftSideFraction, + pairConfig.rightSideFraction, submitOrderEvent.uuid, submitOrderEvent.userLevel, submitOrderEvent.direction, diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/pom.xml b/accountant/accountant-ports/accountant-eventlistener-kafka/pom.xml index a27cadb8f..52c433ffe 100644 --- a/accountant/accountant-ports/accountant-eventlistener-kafka/pom.xml +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/config/AccountantKafkaConfig.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/config/AccountantKafkaConfig.kt index 455ed860f..6ae6f37dd 100644 --- a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/config/AccountantKafkaConfig.kt +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/config/AccountantKafkaConfig.kt @@ -2,7 +2,9 @@ package co.nilin.opex.accountant.ports.kafka.listener.config import co.nilin.opex.accountant.core.inout.KycLevelUpdatedEvent import co.nilin.opex.accountant.ports.kafka.listener.consumer.* +import co.nilin.opex.accountant.ports.kafka.listener.inout.DepositEvent import co.nilin.opex.accountant.ports.kafka.listener.inout.FinancialActionResponseEvent +import co.nilin.opex.accountant.ports.kafka.listener.inout.WithdrawRequestEvent import co.nilin.opex.matching.engine.core.eventh.events.CoreEvent import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.common.TopicPartition @@ -36,7 +38,7 @@ class AccountantKafkaConfig { ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, JsonDeserializer.TRUSTED_PACKAGES to "co.nilin.opex.*", - JsonDeserializer.TYPE_MAPPINGS to "order_request_event:co.nilin.opex.accountant.ports.kafka.listener.inout.OrderRequestEvent,order_request_submit:co.nilin.opex.accountant.ports.kafka.listener.inout.OrderSubmitRequestEvent,order_request_cancel:co.nilin.opex.accountant.ports.kafka.listener.inout.OrderCancelRequestEvent,kyc_level_updated_event:co.nilin.opex.accountant.core.inout.KycLevelUpdatedEvent,fiAction_response_event:co.nilin.opex.accountant.ports.kafka.listener.inout.FinancialActionResponseEvent" + JsonDeserializer.TYPE_MAPPINGS to "order_request_event:co.nilin.opex.accountant.ports.kafka.listener.inout.OrderRequestEvent,order_request_submit:co.nilin.opex.accountant.ports.kafka.listener.inout.OrderSubmitRequestEvent,order_request_cancel:co.nilin.opex.accountant.ports.kafka.listener.inout.OrderCancelRequestEvent,kyc_level_updated_event:co.nilin.opex.accountant.core.inout.KycLevelUpdatedEvent,fiAction_response_event:co.nilin.opex.accountant.ports.kafka.listener.inout.FinancialActionResponseEvent,withdrawRequestEvent:co.nilin.opex.accountant.ports.kafka.listener.inout.WithdrawRequestEvent,depositEvent:co.nilin.opex.accountant.ports.kafka.listener.inout.DepositEvent" ) } @@ -55,6 +57,15 @@ class AccountantKafkaConfig { return DefaultKafkaConsumerFactory(consumerConfigs) } + @Bean("withdrawRequestConsumerFactory") + fun withdrawRequestConsumerFactory(@Qualifier("consumerConfig") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + @Bean("depositConsumerFactory") + fun depositConsumerFactory(@Qualifier("consumerConfig") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + @Autowired @ConditionalOnBean(TradeKafkaListener::class) fun configureTradeListener( @@ -132,12 +143,32 @@ class AccountantKafkaConfig { } @Bean("kycLevelUpdatedProducerFactory") - fun producerFactory(@Qualifier("consumerConfig") producerConfigs: Map): ProducerFactory { + fun kycLevelUpdatedProducerFactory(@Qualifier("consumerConfig") producerConfigs: Map): ProducerFactory { return DefaultKafkaProducerFactory(producerConfigs) } @Bean("kycLevelUpdatedKafkaTemplate") - fun kafkaTemplate(@Qualifier("kycLevelUpdatedProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + fun kycLevelUpdatedKafkaTemplate(@Qualifier("kycLevelUpdatedProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + + @Bean("withdrawRequestProducerFactory") + fun withdrawRequestProducerFactory(@Qualifier("consumerConfig") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean("withdrawRequestKafkaTemplate") + fun withdrawRequestKafkaTemplate(@Qualifier("withdrawRequestProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + + @Bean("depositProducerFactory") + fun depositProducerFactory(@Qualifier("consumerConfig") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean("depositKafkaTemplate") + fun depositKafkaTemplate(@Qualifier("depositProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { return KafkaTemplate(producerFactory) } @@ -156,6 +187,36 @@ class AccountantKafkaConfig { container.start() } + @Autowired + @ConditionalOnBean(WithdrawRequestKafkaListener::class) + fun configureWithdrawRequestEventListener( + listener: WithdrawRequestKafkaListener, + @Qualifier("withdrawRequestKafkaTemplate") template: KafkaTemplate, + @Qualifier("withdrawRequestConsumerFactory") consumerFactory: ConsumerFactory + ) { + val containerProps = ContainerProperties(Pattern.compile("withdraw_request")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("WithdrawRequestKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "withdraw_request.DLT") + container.start() + } + + @Autowired + @ConditionalOnBean(DepositKafkaListener::class) + fun configureDepositRequestEventListener( + listener: DepositKafkaListener, + @Qualifier("depositKafkaTemplate") template: KafkaTemplate, + @Qualifier("depositConsumerFactory") consumerFactory: ConsumerFactory + ) { + val containerProps = ContainerProperties(Pattern.compile("deposit")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("DepositKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "deposit.DLT") + container.start() + } + private fun createConsumerErrorHandler(kafkaTemplate: KafkaTemplate<*, *>, dltTopic: String): CommonErrorHandler { val recoverer = DeadLetterPublishingRecoverer(kafkaTemplate) { cr, _ -> cr.headers().add("dlt-origin-module", "ACCOUNTANT".toByteArray()) diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/consumer/DepositKafkaListener.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/consumer/DepositKafkaListener.kt new file mode 100644 index 000000000..da4347aa9 --- /dev/null +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/consumer/DepositKafkaListener.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.accountant.ports.kafka.listener.consumer + +import co.nilin.opex.accountant.ports.kafka.listener.inout.DepositEvent +import co.nilin.opex.accountant.ports.kafka.listener.spi.DepositListener +import org.springframework.stereotype.Component + +@Component +class DepositKafkaListener : EventConsumer() \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/consumer/WithdrawRequestKafkaListener.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/consumer/WithdrawRequestKafkaListener.kt new file mode 100644 index 000000000..aaef0b48b --- /dev/null +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/consumer/WithdrawRequestKafkaListener.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.accountant.ports.kafka.listener.consumer + +import co.nilin.opex.accountant.ports.kafka.listener.inout.WithdrawRequestEvent +import co.nilin.opex.accountant.ports.kafka.listener.spi.WithdrawRequestListener +import org.springframework.stereotype.Component + +@Component +class WithdrawRequestKafkaListener : EventConsumer() \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/DepositEvent.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/DepositEvent.kt new file mode 100644 index 000000000..37624a599 --- /dev/null +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/DepositEvent.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.accountant.ports.kafka.listener.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class DepositEvent( + val uuid: String, + val depositRef: String? = null, + val currency: String, + val amount: BigDecimal, + val createDate: LocalDateTime, +) diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderRequestEvent.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderRequestEvent.kt index 04876ee6f..f4d99e7b6 100644 --- a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderRequestEvent.kt +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderRequestEvent.kt @@ -2,4 +2,4 @@ package co.nilin.opex.accountant.ports.kafka.listener.inout import co.nilin.opex.matching.engine.core.model.Pair -abstract class OrderRequestEvent(val ouid:String, val uuid: String, val pair: Pair) \ No newline at end of file +abstract class OrderRequestEvent(val ouid: String, val uuid: String, val pair: Pair) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/WithdrawRequestEvent.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/WithdrawRequestEvent.kt new file mode 100644 index 000000000..c76b45cb7 --- /dev/null +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/WithdrawRequestEvent.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.accountant.ports.kafka.listener.inout + +import co.nilin.opex.accountant.core.model.WithdrawStatus +import java.math.BigDecimal +import java.time.LocalDateTime + +data class WithdrawRequestEvent( + val uuid: String, + val withdrawId: Long? = null, + val currency: String, + val amount: BigDecimal, + val status: WithdrawStatus, + val createDate: LocalDateTime, +) diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/spi/DepositListener.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/spi/DepositListener.kt new file mode 100644 index 000000000..765899b4f --- /dev/null +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/spi/DepositListener.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.accountant.ports.kafka.listener.spi + +import co.nilin.opex.accountant.ports.kafka.listener.inout.DepositEvent + +interface DepositListener : Listener \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/spi/WithdrawRequestListener.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/spi/WithdrawRequestListener.kt new file mode 100644 index 000000000..98c63cd37 --- /dev/null +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/spi/WithdrawRequestListener.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.accountant.ports.kafka.listener.spi + +import co.nilin.opex.accountant.ports.kafka.listener.inout.WithdrawRequestEvent + +interface WithdrawRequestListener : Listener \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/pom.xml b/accountant/accountant-ports/accountant-persister-postgres/pom.xml index 15d35337e..b90ec1cf5 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/pom.xml +++ b/accountant/accountant-ports/accountant-persister-postgres/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/CurrencyRateRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/CurrencyRateRepository.kt new file mode 100644 index 000000000..7b9b743b0 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/CurrencyRateRepository.kt @@ -0,0 +1,30 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.ports.postgres.model.CurrencyRateModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.math.BigDecimal + +@Repository +interface CurrencyRateRepository : ReactiveCrudRepository { + + @Query( + """ + insert into currency_rate (base, quote, rate,update_date) + values (:base, :quote, :rate , now()) + on conflict (base, quote) + do update set rate = excluded.rate , update_date = now() + """ + ) + fun createOrUpdate(base: String, quote: String, rate: BigDecimal): Mono + + @Query("select rate from currency_rate where base = :base and quote = :quote") + fun findByBaseAndQuote(base: String, quote: String): Mono + + @Query("select * from currency_rate where quote = :quote") + fun findAllByQuote(quote: String): Flux + +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FeeConfigRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FeeConfigRepository.kt new file mode 100644 index 000000000..c1cefcb8b --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FeeConfigRepository.kt @@ -0,0 +1,45 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.core.model.FeeConfig +import co.nilin.opex.accountant.core.model.UserFee +import co.nilin.opex.accountant.ports.postgres.model.FeeConfigModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.math.BigDecimal + +@Repository +interface FeeConfigRepository : ReactiveCrudRepository { + + @Query("select * from fee_config order by display_order") + fun findAllByOrder(): Flux + + fun findByName(name: String): Mono + + @Query( + """ + SELECT name,display_order,maker_fee,taker_fee + FROM fee_config + WHERE + ( + condition = 'AND' + AND (:assetVolume BETWEEN min_asset_volume AND COALESCE(max_asset_volume, :assetVolume)) + AND (:tradeVolume BETWEEN min_trade_volume AND COALESCE(max_trade_volume, :tradeVolume)) + ) + OR + ( + condition = 'OR' + AND ( + (:assetVolume BETWEEN min_asset_volume AND COALESCE(max_asset_volume, :assetVolume)) + OR (:tradeVolume BETWEEN min_trade_volume AND COALESCE(max_trade_volume, :tradeVolume)) + ) + ) + ORDER BY display_order DESC + LIMIT 1 + """ + ) + fun findMatchingConfig(assetVolume: BigDecimal, tradeVolume: BigDecimal): Mono + +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt index fa04c317d..3139f647d 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt @@ -41,10 +41,12 @@ interface FinancialActionRepository : ReactiveCrudRepository, status: FinancialActionStatus): Mono - @Query(""" + @Query( + """ select * from fi_actions fi where status = 'CREATED' and (parent_id is null or 'ERROR' != (select pfi.status from fi_actions pfi where pfi.id = fi.parent_id)) - """) + """ + ) fun findReadyToProcess(of: Pageable): Flow } \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/PairFeeConfigRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/PairFeeConfigRepository.kt deleted file mode 100644 index 105bdc042..000000000 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/PairFeeConfigRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -package co.nilin.opex.accountant.ports.postgres.dao - -import co.nilin.opex.accountant.ports.postgres.model.PairFeeConfigModel -import co.nilin.opex.matching.engine.core.model.OrderDirection -import org.springframework.data.r2dbc.repository.Query -import org.springframework.data.repository.query.Param -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono - -@Repository -interface PairFeeConfigRepository : ReactiveCrudRepository { - - @Query("select * from pair_fee_config where pair_config_id = :pair and direction = :direction and user_level = :userLevel") - fun findByPairAndDirectionAndUserLevel( - @Param("pair") pair: String, - @Param("direction") direction: OrderDirection, - @Param("userLevel") userLevel: String - ): Mono - - @Query("select * from pair_fee_config where direction = :direction and user_level = :userLevel") - fun findByDirectionAndUserLevel(direction: OrderDirection, userLevel: String): Flux - -} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserDepositVolumeRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserDepositVolumeRepository.kt new file mode 100644 index 000000000..56f5f4c03 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserDepositVolumeRepository.kt @@ -0,0 +1,63 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.ports.postgres.model.UserDepositVolumeModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.math.BigDecimal +import java.time.LocalDate + +@Repository +interface UserDepositVolumeRepository : ReactiveCrudRepository { + + @Query( + """ + insert into user_deposit_volume (user_id, date, total_amount, quote_currency) + values (:userId, :date, :totalAmount, :quoteCurrency) + on conflict (user_id, date,quote_currency) + do update + set total_amount = user_deposit_volume.total_amount + EXCLUDED.total_amount + """ + ) + fun insertOrUpdate( + userId: String, + date: LocalDate, + totalAmount: BigDecimal, + quoteCurrency: String + ): Mono + + + @Query( + """ + select sum(total_amount) as total_Amount + from user_deposit_volume + where user_id = :userId and date >= :startDate and quote_currency=:quoteCurrency + group by user_id + """ + ) + fun findTotalValueByUserAndAndDateAfter( + userId: String, + startDate: LocalDate, + quoteCurrency: String + ): Mono + + + @Query( + """ + select date, total_amount + from user_deposit_volume + where user_id = :userId + and date >= :startDate + and quote_currency = :quoteCurrency + order by date desc + """ + ) + fun findDailyDepositVolume( + userId: String, + startDate: LocalDate, + quoteCurrency: String + ): Flux +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelMapperRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelMapperRepository.kt deleted file mode 100644 index 9156013b6..000000000 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelMapperRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package co.nilin.opex.accountant.ports.postgres.dao - -import co.nilin.opex.accountant.ports.postgres.model.UserLevelMapperModel -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Mono - -@Repository -interface UserLevelMapperRepository : ReactiveCrudRepository { - - fun findByUuid(uuid: String): Mono - -} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelRepository.kt deleted file mode 100644 index 9bf546613..000000000 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelRepository.kt +++ /dev/null @@ -1,17 +0,0 @@ -package co.nilin.opex.accountant.ports.postgres.dao - -import co.nilin.opex.accountant.ports.postgres.model.UserLevelModel -import org.springframework.data.r2dbc.repository.Query -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Mono - -@Repository -interface UserLevelRepository : ReactiveCrudRepository { - - fun findByLevel(level: String): Mono - - @Query("insert into user_level (level) values (:level) on conflict do nothing") - fun insert(level: String): Mono - -} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserTradeVolumeRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserTradeVolumeRepository.kt new file mode 100644 index 000000000..d52675cb0 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserTradeVolumeRepository.kt @@ -0,0 +1,79 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.ports.postgres.model.UserTradeVolumeModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.math.BigDecimal +import java.time.LocalDate + +@Repository +interface UserTradeVolumeRepository : ReactiveCrudRepository { + + @Query( + """ + insert into user_trade_volume (user_id, currency, date, volume, total_amount, quote_currency) + values (:userId, :currency, :date, :volume, :totalAmount, :quoteCurrency) + on conflict (user_id, currency, date,quote_currency) + do update + set volume = user_trade_volume.volume + EXCLUDED.volume, + total_amount = user_trade_volume.total_amount + EXCLUDED.total_amount + """ + ) + fun insertOrUpdate( + userId: String, + currency: String, + date: LocalDate, + volume: BigDecimal, + totalAmount: BigDecimal, + quoteCurrency: String + ): Mono + + @Query( + """ + select sum(total_amount) as total_amount + from user_trade_volume + where user_id = :userId and date >= :startDate and quote_currency = :quoteCurrency + group by user_id + """ + ) + fun findTotalValueByUserAndAndDateAfter( + userId: String, + startDate: LocalDate, + quoteCurrency: String + ): Mono + + @Query( + """ + select sum(total_amount) as total_amount + from user_trade_volume + where user_id = :userId and date >= :startDate and quote_currency = :quoteCurrency and currency = :currency + group by user_id + """ + ) + fun findTotalValueByUserAndAndDateAfterAndCurrency( + userId: String, + currency: String, + startDate: LocalDate, + quoteCurrency: String + ): Mono + + @Query( + """ + select date, sum(total_amount) as total_amount + from user_trade_volume + where user_id = :userId + and date >= :startDate + and quote_currency = :quoteCurrency + group by date + """ + ) + fun findDailyTradeVolume( + userId: String, + startDate: LocalDate, + quoteCurrency: String + ): Flux +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserWithdrawVolumeRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserWithdrawVolumeRepository.kt new file mode 100644 index 000000000..8f65a4a38 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserWithdrawVolumeRepository.kt @@ -0,0 +1,62 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.ports.postgres.model.UserWithdrawVolumeModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.math.BigDecimal +import java.time.LocalDate + +@Repository +interface UserWithdrawVolumeRepository : ReactiveCrudRepository { + + @Query( + """ + insert into user_withdraw_volume (user_id, date, total_amount, quote_currency) + values (:userId, :date, :totalAmount, :quoteCurrency) + on conflict (user_id, date,quote_currency) + do update + set total_amount = user_withdraw_volume.total_amount + EXCLUDED.total_amount + """ + ) + fun insertOrUpdate( + userId: String, + date: LocalDate, + totalAmount: BigDecimal, + quoteCurrency: String + ): Mono + + + @Query( + """ + select sum(total_amount) as total_Amount + from user_withdraw_volume + where user_id = :userId and date >= :startDate and quote_currency=:quoteCurrency + group by user_id + """ + ) + fun findTotalValueByUserAndAndDateAfter( + userId: String, + startDate: LocalDate, + quoteCurrency: String + ): Mono + + @Query( + """ + select date, total_amount + from user_withdraw_volume + where user_id = :userId + and date >= :startDate + and quote_currency = :quoteCurrency + order by date desc + """ + ) + fun findDailyWithdrawVolume( + userId: String, + startDate: LocalDate, + quoteCurrency: String + ): Flux +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/WithdrawLimitConfigRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/WithdrawLimitConfigRepository.kt new file mode 100644 index 000000000..f1e5b7806 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/WithdrawLimitConfigRepository.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.ports.postgres.model.WithdrawLimitConfigModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono + +@Repository +interface WithdrawLimitConfigRepository : ReactiveCrudRepository { + + fun findByUserLevel(userLevel: String): Mono +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/CurrencyRatePersisterImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/CurrencyRatePersisterImpl.kt new file mode 100644 index 000000000..cbeb0dd06 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/CurrencyRatePersisterImpl.kt @@ -0,0 +1,19 @@ +package co.nilin.opex.accountant.ports.postgres.impl + +import co.nilin.opex.accountant.core.spi.CurrencyRatePersister +import co.nilin.opex.accountant.ports.postgres.dao.CurrencyRateRepository +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.stereotype.Component +import java.math.BigDecimal + +@Component +class CurrencyRatePersisterImpl(private val repository: CurrencyRateRepository) : CurrencyRatePersister { + + override suspend fun updateRate(base: String, quote: String, rate: BigDecimal) { + repository.createOrUpdate(base, quote, rate).awaitSingleOrNull() + } + + override suspend fun getRate(base: String, quote: String): BigDecimal { + return repository.findByBaseAndQuote(base, quote).awaitSingleOrNull() ?: BigDecimal.ZERO + } +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FeeConfigServiceImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FeeConfigServiceImpl.kt new file mode 100644 index 000000000..13a8dae4d --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FeeConfigServiceImpl.kt @@ -0,0 +1,65 @@ +package co.nilin.opex.accountant.ports.postgres.impl + +import co.nilin.opex.accountant.core.model.FeeConfig +import co.nilin.opex.accountant.core.model.UserFee +import co.nilin.opex.accountant.core.spi.FeeConfigService +import co.nilin.opex.accountant.ports.postgres.dao.FeeConfigRepository +import co.nilin.opex.accountant.ports.postgres.model.FeeConfigModel +import co.nilin.opex.common.OpexError +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.stereotype.Component +import java.math.BigDecimal + +@Component +class FeeConfigServiceImpl( + private val feeConfigRepository: FeeConfigRepository, +) : FeeConfigService { + + override suspend fun loadFeeConfigs(): List { + return feeConfigRepository.findAllByOrder() + .collectList() + .awaitFirstOrElse { emptyList() } + + } + + override suspend fun saveFee(config: FeeConfig) { + feeConfigRepository.findByName(config.name).awaitFirstOrNull()?.let { + throw OpexError.FeeConfigAlreadyExists.exception("FeeConfig with name '${config.name}' already exists") + } + feeConfigRepository.save(config.asFeeConfigModel()).awaitFirst() + } + + override suspend fun updateFee(config: FeeConfig) { + feeConfigRepository.findByName(config.name).awaitFirstOrNull() + ?: throw OpexError.FeeConfigNotFound.exception("FeeConfig with name '${config.name}' not found") + + feeConfigRepository.save(config.asFeeConfigModel()).awaitFirst() + } + + override suspend fun loadFeeConfig(name: String): FeeConfig { + return feeConfigRepository.findByName(name).awaitFirstOrNull() + ?: throw OpexError.FeeConfigNotFound.exception("FeeConfig with name '${name}' not found") + } + + override suspend fun loadMatchingFeeConfig( + assetVolume: BigDecimal, + tradeVolume: BigDecimal + ): UserFee { + return feeConfigRepository.findMatchingConfig(assetVolume, tradeVolume).awaitFirst() + } + + private fun FeeConfig.asFeeConfigModel() = + FeeConfigModel( + name, + displayOrder, + minAssetVolume, + maxAssetVolume, + minTradeVolume, + maxTradeVolume, + makerFee, + takerFee, + condition + ) +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt index b347977cc..86d9dcd89 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt @@ -3,7 +3,6 @@ package co.nilin.opex.accountant.ports.postgres.impl import co.nilin.opex.accountant.core.model.FinancialAction import co.nilin.opex.accountant.core.model.FinancialActionStatus import co.nilin.opex.accountant.core.spi.FinancialActionPersister -import co.nilin.opex.accountant.core.spi.JsonMapper import co.nilin.opex.accountant.ports.postgres.dao.FinancialActionErrorRepository import co.nilin.opex.accountant.ports.postgres.dao.FinancialActionRepository import co.nilin.opex.accountant.ports.postgres.dao.FinancialActionRetryRepository diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt index 78487926e..5fe137ca4 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt @@ -1,24 +1,17 @@ package co.nilin.opex.accountant.ports.postgres.impl import co.nilin.opex.accountant.core.model.PairConfig -import co.nilin.opex.accountant.core.model.PairFeeConfig import co.nilin.opex.accountant.core.spi.PairConfigLoader import co.nilin.opex.accountant.ports.postgres.dao.PairConfigRepository -import co.nilin.opex.accountant.ports.postgres.dao.PairFeeConfigRepository import co.nilin.opex.accountant.ports.postgres.model.PairConfigModel -import co.nilin.opex.accountant.ports.postgres.model.PairFeeConfigModel import co.nilin.opex.common.OpexError import co.nilin.opex.matching.engine.core.model.OrderDirection import kotlinx.coroutines.reactive.awaitFirstOrElse -import kotlinx.coroutines.reactive.awaitFirstOrNull -import kotlinx.coroutines.reactive.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull import org.springframework.stereotype.Component @Component class PairConfigLoaderImpl( private val pairConfigRepository: PairConfigRepository, - private val pairFeeConfigRepository: PairFeeConfigRepository ) : PairConfigLoader { override suspend fun loadPairConfigs(): List { @@ -28,89 +21,6 @@ class PairConfigLoaderImpl( .map { it.asPairConfig() } } - override suspend fun loadPairFeeConfigs(): List { - return pairFeeConfigRepository.findAll() - .collectList() - .awaitFirstOrElse { emptyList() } - .map { - val pairConfig = pairConfigRepository.findById(it.pairConfigId).awaitSingle().asPairConfig() - PairFeeConfig(pairConfig, it.direction, it.userLevel, it.makerFee, it.takerFee) - } - } - - override suspend fun loadPairFeeConfigs(direction: OrderDirection, userLevel: String): List { - return pairFeeConfigRepository.findByDirectionAndUserLevel(direction, userLevel) - .collectList() - .awaitFirstOrElse { emptyList() } - .map { - val pairConfig = pairConfigRepository.findById(it.pairConfigId).awaitSingle().asPairConfig() - PairFeeConfig( - pairConfig, - it.direction, - it.userLevel, - it.makerFee, - it.takerFee - ) - } - } - - override suspend fun loadPairFeeConfigs( - pair: String, - direction: OrderDirection, - userLevel: String - ): PairFeeConfig? { - val fee = pairFeeConfigRepository.findByPairAndDirectionAndUserLevel(pair, direction, userLevel) - .awaitSingleOrNull() ?: return null - val pairConfig = pairConfigRepository.findById(fee.pairConfigId).awaitSingle().asPairConfig() - return PairFeeConfig( - pairConfig, - fee.direction, - fee.userLevel, - fee.makerFee, - fee.takerFee - ) - } - - override suspend fun load(pair: String, direction: OrderDirection, userLevel: String): PairFeeConfig { - val pairConfig = pairConfigRepository.findById(pair).awaitFirstOrElse { - throw OpexError.InvalidPair.messageFormattedException(pair) - } - - var pairFeeConfig: PairFeeConfigModel? - if (userLevel.isEmpty()) { - pairFeeConfig = pairFeeConfigRepository - .findByPairAndDirectionAndUserLevel(pair, direction, "*") - .awaitFirstOrElse { - throw OpexError.InvalidPair.messageFormattedException(pair) - } - } else { - pairFeeConfig = pairFeeConfigRepository - .findByPairAndDirectionAndUserLevel(pair, direction, userLevel) - .awaitFirstOrNull() - if (pairFeeConfig == null) { - pairFeeConfig = pairFeeConfigRepository - .findByPairAndDirectionAndUserLevel(pair, direction, "*") - .awaitFirstOrElse { - throw OpexError.InvalidPair.messageFormattedException(pair) - } - } - } - - return PairFeeConfig( - PairConfig( - pair, - pairConfig.leftSideWalletSymbol, - pairConfig.rightSideWalletSymbol, - pairConfig.leftSideFraction, - pairConfig.rightSideFraction - ), - pairFeeConfig!!.direction, - pairFeeConfig.userLevel, - pairFeeConfig.makerFee, - pairFeeConfig.takerFee - ) - } - override suspend fun load(pair: String, direction: OrderDirection): PairConfig { return pairConfigRepository .findById(pair).awaitFirstOrElse { diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/TempEventPersisterImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/TempEventPersisterImpl.kt index 8bfa95402..2aed7a703 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/TempEventPersisterImpl.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/TempEventPersisterImpl.kt @@ -6,7 +6,6 @@ import co.nilin.opex.accountant.ports.postgres.dao.TempEventRepository import co.nilin.opex.accountant.ports.postgres.model.TempEventModel import co.nilin.opex.matching.engine.core.eventh.events.CoreEvent import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactive.awaitFirstOrNull diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserDepositVolumePersisterImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserDepositVolumePersisterImpl.kt new file mode 100644 index 000000000..580c3ca36 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserDepositVolumePersisterImpl.kt @@ -0,0 +1,82 @@ +package co.nilin.opex.accountant.ports.postgres.impl + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.model.WithdrawStatus +import co.nilin.opex.accountant.core.spi.CurrencyRatePersister +import co.nilin.opex.accountant.core.spi.UserDepositVolumePersister +import co.nilin.opex.accountant.ports.postgres.dao.UserDepositVolumeRepository +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.math.RoundingMode +import java.time.LocalDate + + +@Component +class UserDepositVolumePersisterImpl( + private val repository: UserDepositVolumeRepository, + private val currencyRatePersister: CurrencyRatePersister, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, + @Value("\${app.deposit-volume-calculation-currency}") private val calculationCurrency: String, + @Value("\${app.deposit-volume-calculation-currency-precision:2}") private val calculationCurrencyPrecision: Int + +) : UserDepositVolumePersister { + + override suspend fun update( + userId: String, + currency: String, + amount: BigDecimal, + date: LocalDateTime + ) { + val rate = if (currency == calculationCurrency) BigDecimal.ONE + else currencyRatePersister.getRate(currency, calculationCurrency) + + val signedAmount = amount.multiply(rate).setScale(calculationCurrencyPrecision, RoundingMode.DOWN) + + repository.insertOrUpdate( + userId, + date.atOffset(ZoneOffset.of(zoneOffsetString)).toLocalDate(), + signedAmount, + calculationCurrency + ).awaitSingleOrNull() + } + + override suspend fun getTotalValueByUserAndDateAfter( + uuid: String, + startDate: LocalDateTime + ): BigDecimal { + return repository.findTotalValueByUserAndAndDateAfter( + uuid, + startDate.atOffset(ZoneOffset.of(zoneOffsetString)).toLocalDate(), + calculationCurrency + ).awaitFirstOrNull() ?: BigDecimal.ZERO + } + + override suspend fun getLastDaysDeposit( + userId: String, + startDate: LocalDate?, + quatCurrency: String?, + lastDays: Long + ): List { + + val startDate = startDate ?: LocalDate + .now(ZoneOffset.of(zoneOffsetString)) + .minusDays(lastDays) + + return repository + .findDailyDepositVolume(userId, startDate, quatCurrency?:calculationCurrency) + .map { + DailyAmount( + date = it.date, + totalAmount = it.totalAmount + ) + } + .collectList() + .awaitSingle() + } +} diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserLevelLoaderImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserLevelLoaderImpl.kt deleted file mode 100644 index ac2817040..000000000 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserLevelLoaderImpl.kt +++ /dev/null @@ -1,35 +0,0 @@ -package co.nilin.opex.accountant.ports.postgres.impl - -import co.nilin.opex.accountant.core.model.KycLevel -import co.nilin.opex.accountant.core.spi.UserLevelLoader -import co.nilin.opex.accountant.ports.postgres.dao.UserLevelMapperRepository -import co.nilin.opex.accountant.ports.postgres.dao.UserLevelRepository -import co.nilin.opex.accountant.ports.postgres.model.UserLevelMapperModel -import kotlinx.coroutines.reactor.awaitSingleOrNull -import org.springframework.stereotype.Component - -@Component -class UserLevelLoaderImpl(private val userLevelMapperRepository: UserLevelMapperRepository, - private val userLevelRepository: UserLevelRepository) : UserLevelLoader { - - override suspend fun load(uuid: String): String { - val mapper = userLevelMapperRepository.findByUuid(uuid).awaitSingleOrNull() - return mapper?.userLevel ?: "*" - } - - override suspend fun update(uuid: String, userLevel: KycLevel) { - - userLevelRepository.findByLevel(userLevel.name).awaitSingleOrNull()?.let { - userLevelMapperRepository.findByUuid(uuid).awaitSingleOrNull() - ?.let { userLevelMapperRepository.save(UserLevelMapperModel(it.id, it.uuid, userLevel.name)).awaitSingleOrNull() } - ?: run { userLevelMapperRepository.save(UserLevelMapperModel(null, uuid, userLevel.name)).awaitSingleOrNull() } - }?: - run { - userLevelRepository.insert(userLevel.name) .awaitSingleOrNull() - userLevelMapperRepository.findByUuid(uuid).awaitSingleOrNull() - ?.let { userLevelMapperRepository.save(UserLevelMapperModel(it.id, it.uuid, userLevel.name)).awaitSingleOrNull() } - ?: run { userLevelMapperRepository.save(UserLevelMapperModel(null, uuid, userLevel.name)).awaitSingleOrNull() } - - } - } -} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserTradeVolumePersisterImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserTradeVolumePersisterImpl.kt new file mode 100644 index 000000000..9291c0d36 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserTradeVolumePersisterImpl.kt @@ -0,0 +1,73 @@ +package co.nilin.opex.accountant.ports.postgres.impl + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.spi.UserTradeVolumePersister +import co.nilin.opex.accountant.ports.postgres.dao.UserTradeVolumeRepository +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDate +import java.time.ZoneOffset + +@Component +class UserTradeVolumePersisterImpl( + private val repository: UserTradeVolumeRepository, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, + @Value("\${app.trade-volume-calculation-currency}") private val calculationCurrency: String, + + ) : UserTradeVolumePersister { + + override suspend fun update( + userId: String, + currency: String, + date: LocalDate, + volume: BigDecimal, + totalAmount: BigDecimal, + quoteCurrency: String + ) { + repository.insertOrUpdate(userId, currency, date, volume, totalAmount, quoteCurrency).awaitSingleOrNull() + } + + override suspend fun getUserTotalTradeVolume( + uuid: String, + startDate: LocalDate, + quoteCurrency: String + ): BigDecimal? { + return repository.findTotalValueByUserAndAndDateAfter(uuid, startDate, quoteCurrency).awaitSingleOrNull() + } + + override suspend fun getUserTotalTradeVolumeByCurrency( + uuid: String, + currency: String, + startDate: LocalDate, + quoteCurrency: String + ): BigDecimal? { + return repository.findTotalValueByUserAndAndDateAfterAndCurrency(uuid, currency, startDate, quoteCurrency) + .awaitSingleOrNull() + } + + override suspend fun getLastDaysTrade( + userId: String, + startDate: LocalDate?, + quatCurrency: String?, + lastDays: Long + ): List { + + val startDate = startDate ?: LocalDate + .now(ZoneOffset.of(zoneOffsetString)) + .minusDays(lastDays) + + return repository + .findDailyTradeVolume(userId, startDate, quatCurrency ?: calculationCurrency) + .map { + DailyAmount( + date = it.date, + totalAmount = it.totalAmount + ) + } + .collectList() + .awaitSingle() + } +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserWithdrawVolumePersisterImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserWithdrawVolumePersisterImpl.kt new file mode 100644 index 000000000..6e72f5bcc --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserWithdrawVolumePersisterImpl.kt @@ -0,0 +1,84 @@ +package co.nilin.opex.accountant.ports.postgres.impl + +import co.nilin.opex.accountant.core.model.DailyAmount +import co.nilin.opex.accountant.core.model.WithdrawStatus +import co.nilin.opex.accountant.core.spi.CurrencyRatePersister +import co.nilin.opex.accountant.core.spi.UserWithdrawVolumePersister +import co.nilin.opex.accountant.ports.postgres.dao.UserWithdrawVolumeRepository +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset + + +@Component +class UserWithdrawVolumePersisterImpl( + private val repository: UserWithdrawVolumeRepository, + private val currencyRatePersister: CurrencyRatePersister, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, + @Value("\${app.withdraw-volume-calculation-currency}") private val calculationCurrency: String, + @Value("\${app.withdraw-volume-calculation-currency-precision:2}") private val calculationCurrencyPrecision: Int + +) : UserWithdrawVolumePersister { + + override suspend fun update( + userId: String, + currency: String, + amount: BigDecimal, + date: LocalDateTime, + withdrawStatus: WithdrawStatus + ) { + val rate = if (currency == calculationCurrency) BigDecimal.ONE + else currencyRatePersister.getRate(currency, calculationCurrency) + + val signedAmount = amount.multiply(rate).setScale(calculationCurrencyPrecision, RoundingMode.DOWN) + .let { if (withdrawStatus == WithdrawStatus.CANCELED || withdrawStatus == WithdrawStatus.REJECTED) it.negate() else it } + + repository.insertOrUpdate( + userId, + date.atOffset(ZoneOffset.of(zoneOffsetString)).toLocalDate(), + signedAmount, + calculationCurrency + ).awaitSingleOrNull() + } + + override suspend fun getTotalValueByUserAndDateAfter( + uuid: String, + startDate: LocalDateTime + ): BigDecimal { + return repository.findTotalValueByUserAndAndDateAfter( + uuid, + startDate.atOffset(ZoneOffset.of(zoneOffsetString)).toLocalDate(), + calculationCurrency + ).awaitFirstOrNull() ?: BigDecimal.ZERO + } + + override suspend fun getLastDaysWithdraw( + userId: String, + startDate: LocalDate?, + quatCurrency: String?, + lastDays: Long + ): List { + + val startDate = startDate ?: LocalDate + .now(ZoneOffset.of(zoneOffsetString)) + .minusDays(lastDays) + + return repository + .findDailyWithdrawVolume(userId, startDate, quatCurrency?:calculationCurrency) + .map { + DailyAmount( + date = it.date, + totalAmount = it.totalAmount + ) + } + .collectList() + .awaitSingle() + } +} diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/WithdrawLimitManagerImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/WithdrawLimitManagerImpl.kt new file mode 100644 index 000000000..62f69f640 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/WithdrawLimitManagerImpl.kt @@ -0,0 +1,55 @@ +package co.nilin.opex.accountant.ports.postgres.impl + +import co.nilin.opex.accountant.core.model.WithdrawLimitConfig +import co.nilin.opex.accountant.core.spi.CurrencyRatePersister +import co.nilin.opex.accountant.core.spi.WithdrawLimitManager +import co.nilin.opex.accountant.ports.postgres.dao.UserWithdrawVolumeRepository +import co.nilin.opex.accountant.ports.postgres.dao.WithdrawLimitConfigRepository +import co.nilin.opex.common.OpexError +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDateTime +import java.time.ZoneOffset + +@Component +class WithdrawLimitManagerImpl( + private val userWithdrawVolumeRepository: UserWithdrawVolumeRepository, + private val withdrawLimitConfigRepository: WithdrawLimitConfigRepository, + private val currencyRatePersister: CurrencyRatePersister, + @Value("\${app.zone-offset}") private val zoneOffsetString: String, + @Value("\${app.withdraw-volume-calculation-currency}") private val calculationCurrency: String + +) : WithdrawLimitManager { + + override suspend fun canWithdraw(uuid: String, userLevel: String, currency: String, amount: BigDecimal): Boolean { + + val rate = if (currency == calculationCurrency) BigDecimal.ONE + else currencyRatePersister.getRate(currency, calculationCurrency) + + val withdrawLimitConfig = (withdrawLimitConfigRepository.findByUserLevel(userLevel).awaitFirstOrNull() + ?: throw OpexError.WithdrawLimitConfigNotFound.exception()) + + val userWithdrawVolume = userWithdrawVolumeRepository.findTotalValueByUserAndAndDateAfter( + uuid, + LocalDateTime.now().atOffset(ZoneOffset.of(zoneOffsetString)).toLocalDate(), + calculationCurrency + ).awaitFirstOrNull() + + return ((userWithdrawVolume ?: BigDecimal.ZERO) + (amount.multiply(rate))) <= withdrawLimitConfig.dailyMaxAmount + } + + override suspend fun getAll(): List { + return withdrawLimitConfigRepository.findAll() + .map { model -> + WithdrawLimitConfig( + name = model.name, + dailyMaxAmount = model.dailyMaxAmount + ) + } + .collectList() + .awaitFirst() + } +} \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/CurrencyRateModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/CurrencyRateModel.kt similarity index 59% rename from market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/CurrencyRateModel.kt rename to accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/CurrencyRateModel.kt index 490f622da..ab21c2c48 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/CurrencyRateModel.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/CurrencyRateModel.kt @@ -1,15 +1,15 @@ -package co.nilin.opex.market.ports.postgres.model +package co.nilin.opex.accountant.ports.postgres.model -import co.nilin.opex.market.core.inout.RateSource import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Table import java.math.BigDecimal +import java.time.LocalDateTime @Table("currency_rate") data class CurrencyRateModel( - @Id val id: Long? = null, val base: String, val quote: String, - val source: RateSource, - val rate: BigDecimal + val rate: BigDecimal, + @Id val id: Long? = null, + val updateDate : LocalDateTime = LocalDateTime.now() ) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/FeeConfigModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/FeeConfigModel.kt new file mode 100644 index 000000000..49446a51c --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/FeeConfigModel.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.accountant.ports.postgres.model + +import co.nilin.opex.accountant.core.model.Condition +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal + +@Table(name = "fee_config") +data class FeeConfigModel( + @Id + val name: String, + val displayOrder: Int, + val minAssetVolume: BigDecimal, + val maxAssetVolume: BigDecimal? = null, + val minTradeVolume: BigDecimal, + val maxTradeVolume: BigDecimal? = null, + val makerFee: BigDecimal, + val takerFee: BigDecimal, + val condition: Condition, +) + diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/FinancialActionModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/FinancialActionModel.kt index 59d10c675..a8b79712a 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/FinancialActionModel.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/FinancialActionModel.kt @@ -4,7 +4,6 @@ import co.nilin.opex.accountant.core.model.FinancialActionCategory import co.nilin.opex.accountant.core.model.FinancialActionStatus import co.nilin.opex.accountant.core.model.WalletType import org.springframework.data.annotation.Id -import org.springframework.data.relational.core.mapping.Column import org.springframework.data.relational.core.mapping.Table import java.math.BigDecimal import java.time.LocalDateTime diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/PairFeeConfigModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/PairFeeConfigModel.kt deleted file mode 100644 index cd46a2abc..000000000 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/PairFeeConfigModel.kt +++ /dev/null @@ -1,15 +0,0 @@ -package co.nilin.opex.accountant.ports.postgres.model - -import org.springframework.data.relational.core.mapping.Column -import org.springframework.data.relational.core.mapping.Table -import java.math.BigDecimal - -@Table("pair_fee_config") -data class PairFeeConfigModel( - val id: Long?, - @Column("pair_config_id") val pairConfigId: String, - @Column("direction") val direction: String, - @Column("user_level") val userLevel: String, - @Column("maker_fee") val makerFee: BigDecimal, - @Column("taker_fee") val takerFee: BigDecimal -) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserDepositVolumeModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserDepositVolumeModel.kt new file mode 100644 index 000000000..5f0cbead1 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserDepositVolumeModel.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.accountant.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal +import java.time.LocalDate + +@Table("user_deposit_volume") +data class UserDepositVolumeModel( + @Id val id: Long? = null, + val userId: String, + val date: LocalDate, + val totalAmount: BigDecimal, + val quoteCurrency: String, +) diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelMapperModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelMapperModel.kt deleted file mode 100644 index f2f2f08f3..000000000 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelMapperModel.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.nilin.opex.accountant.ports.postgres.model - -import org.springframework.data.annotation.Id -import org.springframework.data.relational.core.mapping.Table - -@Table("user_level_mapper") -data class UserLevelMapperModel( - @Id val id: Long?, - val uuid: String, - val userLevel: String -) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelModel.kt deleted file mode 100644 index 2e9af91b0..000000000 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelModel.kt +++ /dev/null @@ -1,7 +0,0 @@ -package co.nilin.opex.accountant.ports.postgres.model - -import org.springframework.data.annotation.Id -import org.springframework.data.relational.core.mapping.Table - -@Table("user_level") -data class UserLevelModel(@Id val level: String) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserTradeVolumeModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserTradeVolumeModel.kt new file mode 100644 index 000000000..f7b4b6f7e --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserTradeVolumeModel.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.accountant.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal +import java.time.LocalDate + +@Table("user_trade_volume") +class UserTradeVolumeModel( + val userId: String, + val currency: String, + val date: LocalDate, + val volume: BigDecimal, + val totalAmount: BigDecimal, + val quoteCurrency: String, + @Id val id: Long? = null +) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserWithdrawVolumeModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserWithdrawVolumeModel.kt new file mode 100644 index 000000000..caa4ec072 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserWithdrawVolumeModel.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.accountant.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal +import java.time.LocalDate + +@Table("user_withdraw_volume") +data class UserWithdrawVolumeModel( + @Id val id: Long? = null, + val userId: String, + val date: LocalDate, + val totalAmount: BigDecimal, + val quoteCurrency: String, +) diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/WithdrawLimitConfigModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/WithdrawLimitConfigModel.kt new file mode 100644 index 000000000..c3b283a01 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/WithdrawLimitConfigModel.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.accountant.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal + +@Table("withdraw_limit_config") +data class WithdrawLimitConfigModel( + @Id val name: String, + val userLevel: String, + val dailyMaxAmount: BigDecimal, + + ) diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql b/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql index beeddfa88..c4d6999c6 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql @@ -45,10 +45,10 @@ CREATE TABLE IF NOT EXISTS fi_actions create_date TIMESTAMP NOT NULL, status VARCHAR(20) ); -CREATE INDEX IF NOT EXISTS idx_fi_actions_symbol ON fi_actions(symbol); -CREATE INDEX IF NOT EXISTS idx_fi_event_type ON fi_actions(event_type); -CREATE INDEX IF NOT EXISTS idx_fi_actions_status ON fi_actions(status); -CREATE INDEX IF NOT EXISTS idx_fi_actions_pointer ON fi_actions(pointer); +CREATE INDEX IF NOT EXISTS idx_fi_actions_symbol ON fi_actions (symbol); +CREATE INDEX IF NOT EXISTS idx_fi_event_type ON fi_actions (event_type); +CREATE INDEX IF NOT EXISTS idx_fi_actions_status ON fi_actions (status); +CREATE INDEX IF NOT EXISTS idx_fi_actions_pointer ON fi_actions (pointer); ALTER TABLE fi_actions ADD COLUMN IF NOT EXISTS category_name VARCHAR(36); @@ -84,36 +84,79 @@ CREATE TABLE IF NOT EXISTS pair_config UNIQUE (left_side_wallet_symbol, right_side_wallet_symbol) ); -CREATE TABLE IF NOT EXISTS user_level +CREATE TABLE IF NOT EXISTS temp_events +( + id SERIAL PRIMARY KEY, + ouid VARCHAR(72) NOT NULL, + event_type VARCHAR(72) NOT NULL, + event_body TEXT NOT NULL, + event_date TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_trade_volume ( - level VARCHAR(36) PRIMARY KEY + id SERIAL PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + currency TEXT NOT NULL, + date DATE not null, + volume decimal not null, + total_amount decimal not null, + quote_currency VARCHAR(50) NOT NULL, + unique (user_id, currency, date , quote_currency) ); -CREATE TABLE IF NOT EXISTS pair_fee_config +CREATE TABLE IF NOT EXISTS currency_rate ( - id SERIAL PRIMARY KEY, - pair_config_id VARCHAR(72) NOT NULL REFERENCES pair_config (pair), - direction VARCHAR(36) NOT NULL, - user_level VARCHAR(36) NOT NULL REFERENCES user_level (level), - maker_fee DECIMAL NOT NULL, - taker_fee DECIMAL NOT NULL, - UNIQUE (direction, user_level, pair_config_id) + id SERIAL PRIMARY KEY, + base VARCHAR(25) NOT NULL, + quote VARCHAR(25) NOT NULL, + rate DECIMAL NOT NULL DEFAULT 0, + update_date TIMESTAMP NOT NULL, + UNIQUE (base, quote) ); -CREATE TABLE IF NOT EXISTS user_level_mapper + CREATE TABLE IF NOT EXISTS fee_config ( + name VARCHAR(50) PRIMARY KEY, + display_order INTEGER NOT NULL UNIQUE, + min_asset_volume Decimal NOT NULL, + max_asset_volume Decimal, + min_trade_volume Decimal NOT NULL, + max_trade_volume Decimal, + maker_fee Decimal NOT NULL, + taker_fee Decimal NOT NULL, + condition VARCHAR(10) NOT NULL + ); + + DROP TABLE IF EXISTS pair_fee_config; + DROP TABLE IF EXISTS user_fee; + DROP TABLE IF EXISTS user_level_mapper; + DROP TABLE IF EXISTS user_level; + +CREATE TABLE IF NOT EXISTS user_withdraw_volume ( id SERIAL PRIMARY KEY, - uuid VARCHAR(36) NOT NULL UNIQUE, - user_level VARCHAR(36) NOT NULL REFERENCES user_level (level) + user_id VARCHAR(36) NOT NULL, + date DATE not null, + total_amount decimal not null, + quote_currency VARCHAR(50) NOT NULL, + unique (user_id, date,quote_currency) ); -CREATE TABLE IF NOT EXISTS temp_events +CREATE TABLE IF NOT EXISTS withdraw_limit_config +( + name VARCHAR(50) PRIMARY KEY, + user_level VARCHAR(20) NOT NULL UNIQUE, + daily_max_amount decimal not null +); + +CREATE TABLE IF NOT EXISTS user_deposit_volume ( id SERIAL PRIMARY KEY, - ouid VARCHAR(72) NOT NULL, - event_type VARCHAR(72) NOT NULL, - event_body TEXT NOT NULL, - event_date TIMESTAMP NOT NULL + user_id VARCHAR(36) NOT NULL, + date DATE not null, + total_amount decimal not null, + quote_currency VARCHAR(50) NOT NULL, + unique (user_id, date,quote_currency) ); COMMIT; diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/PairConfigLoaderTest.kt b/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/PairConfigLoaderTest.kt index 73da00a7f..37a22ed39 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/PairConfigLoaderTest.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/PairConfigLoaderTest.kt @@ -1,13 +1,11 @@ package co.nilin.opex.accountant.ports.postgres import co.nilin.opex.accountant.ports.postgres.dao.PairConfigRepository -import co.nilin.opex.accountant.ports.postgres.dao.PairFeeConfigRepository import co.nilin.opex.accountant.ports.postgres.impl.PairConfigLoaderImpl import co.nilin.opex.matching.engine.core.model.OrderDirection import co.nilin.opex.utility.error.data.OpexException import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -22,11 +20,7 @@ class PairConfigLoaderTest { every { findAll() } returns Flux.just(Valid.pairConfigModel, Valid.pairConfigModel) every { findById(any() as String) } returns Mono.just(Valid.pairConfigModel) } - private val pairFeeConfigRepository = mockk { - every { findAll() } returns Flux.just(Valid.pairFeeConfigModel, Valid.pairFeeConfigModel) - every { findByPairAndDirectionAndUserLevel(any(), any(), any()) } returns Mono.just(Valid.pairFeeConfigModel) - } - private val pairConfigLoader = PairConfigLoaderImpl(pairConfigRepository, pairFeeConfigRepository) + private val pairConfigLoader = PairConfigLoaderImpl(pairConfigRepository) @Test fun givenPairConfigs_whenListNotEmpty_resultIsNotEmptyAndValid(): Unit = runBlocking { @@ -41,84 +35,14 @@ class PairConfigLoaderTest { } } - @Test - fun givenPairFeeConfigs_whenListNotEmpty_resultIsNotEmptyAndValid(): Unit = runBlocking { - val configs = pairConfigLoader.loadPairFeeConfigs() - assertThat(configs.size).isEqualTo(2) - with(configs[1]) { - assertThat(pairConfig.pair).isEqualTo(Valid.pairConfig.pair) - assertThat(userLevel).isEqualTo(Valid.pairFeeConfigModel.userLevel) - assertThat(direction).isEqualTo(Valid.pairFeeConfigModel.direction) - assertThat(makerFee).isEqualTo(Valid.pairFeeConfigModel.makerFee) - assertThat(takerFee).isEqualTo(Valid.pairFeeConfigModel.takerFee) - } - } - @Test fun givenPairDirectionUserLevel_whenPairConfigNotFound_throwsException(): Unit = runBlocking { every { pairConfigRepository.findById(any() as String) } returns Mono.empty() assertThatThrownBy { - runBlocking { pairConfigLoader.load("BTC_USDT", OrderDirection.BID, "*") } - }.isInstanceOf(OpexException::class.java) - } - - @Test - fun givenPairDirection_whenUserLevelEmpty_loadWithDefaultUserLevel(): Unit = runBlocking { - val pair = pairConfigLoader.load("BTC_USDT", OrderDirection.BID, "") - assertThat(pair).isNotNull - verify(exactly = 1) { - pairFeeConfigRepository.findByPairAndDirectionAndUserLevel( - eq("BTC_USDT"), - eq(OrderDirection.BID), - eq("*") - ) - } - } - - @Test - fun givenPairDirection_whenPairFeeConfigNotFound_throwsException(): Unit = runBlocking { - every { - pairFeeConfigRepository.findByPairAndDirectionAndUserLevel(any(), any(), eq("*")) - } returns Mono.empty() - - assertThatThrownBy { - runBlocking { pairConfigLoader.load("BTC_USDT", OrderDirection.BID, "") } + runBlocking { pairConfigLoader.load("BTC_USDT", OrderDirection.BID) } }.isInstanceOf(OpexException::class.java) } - @Test - fun givenPairDirectionUserLevel_whenPairFeeConfigNotFound_loadWithDefaultUserLevel(): Unit = runBlocking { - every { - pairFeeConfigRepository.findByPairAndDirectionAndUserLevel(any(), any(), eq("1")) - } returns Mono.empty() - - val pair = pairConfigLoader.load("BTC_USDT", OrderDirection.BID, "1") - assertThat(pair).isNotNull - verify(exactly = 1) { - pairFeeConfigRepository.findByPairAndDirectionAndUserLevel( - eq("BTC_USDT"), - eq(OrderDirection.BID), - eq("*") - ) - } - } - - @Test - fun givenPairDirectionUserLevel_whenPairFeeConfigNotFoundWithActualAndDefaultUserLevel_throwsException(): Unit = - runBlocking { - every { - pairFeeConfigRepository.findByPairAndDirectionAndUserLevel(any(), any(), eq("1")) - } returns Mono.empty() - - every { - pairFeeConfigRepository.findByPairAndDirectionAndUserLevel(any(), any(), eq("*")) - } returns Mono.empty() - - assertThatThrownBy { - runBlocking { pairConfigLoader.load("BTC_USDT", OrderDirection.BID, "1") } - }.isInstanceOf(OpexException::class.java) - } - @Test fun givenPairDirection_whenPairConfigNotFound_throwException(): Unit = runBlocking { every { pairConfigRepository.findById(any() as String) } returns Mono.empty() diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/Valid.kt b/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/Valid.kt index af2eb5f88..b96047449 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/Valid.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/Valid.kt @@ -85,15 +85,6 @@ object Valid { 0.01.toBigDecimal() ) - val pairFeeConfigModel = PairFeeConfigModel( - 1, - "BTC_USDT", - "BID", - "1", - 0.01.toBigDecimal(), - 0.01.toBigDecimal() - ) - class TestCoreEvent(val leftSidePair: String, val rightSidePair: String) : CoreEvent(Pair(leftSidePair, rightSidePair), currentTime) diff --git a/accountant/accountant-ports/accountant-submitter-kafka/pom.xml b/accountant/accountant-ports/accountant-submitter-kafka/pom.xml index 2759d98c3..353a37eba 100644 --- a/accountant/accountant-ports/accountant-submitter-kafka/pom.xml +++ b/accountant/accountant-ports/accountant-submitter-kafka/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/accountant/accountant-ports/accountant-wallet-proxy/pom.xml b/accountant/accountant-ports/accountant-wallet-proxy/pom.xml index 3819743c8..17fecd3ba 100644 --- a/accountant/accountant-ports/accountant-wallet-proxy/pom.xml +++ b/accountant/accountant-ports/accountant-wallet-proxy/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/config/WebClientConfig.kt b/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/config/WebClientConfig.kt index b559aa504..2ba1c3c02 100644 --- a/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/config/WebClientConfig.kt +++ b/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/config/WebClientConfig.kt @@ -5,7 +5,6 @@ import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalanc import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.web.reactive.function.client.WebClient import org.zalando.logbook.Logbook import org.zalando.logbook.netty.LogbookClientHandler diff --git a/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/proxy/WalletProxyImpl.kt b/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/proxy/WalletProxyImpl.kt index c9ed17d3e..ff7078afd 100644 --- a/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/proxy/WalletProxyImpl.kt +++ b/accountant/accountant-ports/accountant-wallet-proxy/src/main/kotlin/co/nilin/opex/accountant/ports/walletproxy/proxy/WalletProxyImpl.kt @@ -1,10 +1,13 @@ package co.nilin.opex.accountant.ports.walletproxy.proxy +import co.nilin.opex.accountant.core.model.CurrencyPrice +import co.nilin.opex.accountant.core.model.TotalAssetsSnapshot import co.nilin.opex.accountant.core.model.WalletType import co.nilin.opex.accountant.core.spi.WalletProxy import co.nilin.opex.accountant.ports.walletproxy.data.BooleanResponse import co.nilin.opex.accountant.ports.walletproxy.data.TransferResult import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull import org.springframework.beans.factory.annotation.Value import org.springframework.http.MediaType import org.springframework.stereotype.Component @@ -56,4 +59,26 @@ class WalletProxyImpl( .awaitFirst() .result } + + override suspend fun getUserTotalAssets( + uuid: String, + ): TotalAssetsSnapshot? { + return webClient.get() + .uri("$walletBaseUrl/stats/total-assets/$uuid") + .header("Content-Type", "application/json") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrNull() + } + + override suspend fun getPrices(quote: String): List { + return webClient.get() + .uri("$walletBaseUrl/otc/currency/price?unit=$quote") + .header("Content-Type", "application/json") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirst() + } } \ No newline at end of file diff --git a/accountant/pom.xml b/accountant/pom.xml index 15916c8d3..edce57a88 100644 --- a/accountant/pom.xml +++ b/accountant/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -84,8 +84,8 @@ co.nilin.opex.utility - preferences - ${preferences.version} + interceptors + ${interceptor.version} org.springframework.cloud diff --git a/api/api-app/Dockerfile b/api/api-app/Dockerfile index 2adbda7e1..f7086bdca 100644 --- a/api/api-app/Dockerfile +++ b/api/api-app/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:21 +FROM eclipse-temurin:21-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] diff --git a/api/api-app/pom.xml b/api/api-app/pom.xml index d27d6e876..07c13842b 100644 --- a/api/api-app/pom.xml +++ b/api/api-app/pom.xml @@ -52,27 +52,33 @@ api-binance-rest - co.nilin.opex.api.ports.postgres - api-persister-postgres + co.nilin.opex.api.ports.opex + api-opex-rest + 1.0.1-beta.7 - io.springfox - springfox-boot-starter - 3.0.0 + co.nilin.opex.api.ports.postgres + api-persister-postgres + org.springframework.cloud spring-cloud-starter-vault-config - - co.nilin.opex.utility - preferences - io.micrometer micrometer-registry-prometheus runtime + + com.github.vladimir-bukhtoyarov + bucket4j-core + 8.0.1 + + + org.springframework.boot + spring-boot-starter-data-redis + diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt index 35df759a8..e1c0b542a 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt @@ -2,10 +2,8 @@ package co.nilin.opex.api.app import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication -import org.springframework.cache.annotation.EnableCaching import org.springframework.context.annotation.ComponentScan import org.springframework.scheduling.annotation.EnableScheduling -import springfox.documentation.swagger2.annotations.EnableSwagger2 @SpringBootApplication @ComponentScan("co.nilin.opex") diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt index 5127560d8..564772a2e 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt @@ -1,15 +1,39 @@ package co.nilin.opex.api.app.config +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.cache.CacheManager import org.springframework.cache.annotation.EnableCaching import org.springframework.cache.concurrent.ConcurrentMapCacheManager import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer @Configuration @EnableCaching class CacheConfig { + @Bean + fun redisTemplate(connectionFactory: RedisConnectionFactory, mapper: ObjectMapper): RedisTemplate { + val newMapper = mapper.copy().apply { + activateDefaultTyping(mapper.polymorphicTypeValidator, ObjectMapper.DefaultTyping.EVERYTHING) + findAndRegisterModules() + registerKotlinModule() + } + return RedisTemplate().apply { + setConnectionFactory(connectionFactory) + val ser = GenericJackson2JsonRedisSerializer(newMapper) + valueSerializer = ser + hashValueSerializer = ser + keySerializer = StringRedisSerializer() + hashKeySerializer = StringRedisSerializer() + afterPropertiesSet() + } + } + @Bean fun apiKeyCacheManager(): CacheManager { return ConcurrentMapCacheManager("apiKey") diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CorsConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CorsConfig.kt new file mode 100644 index 000000000..46abeeb1e --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CorsConfig.kt @@ -0,0 +1,51 @@ +package co.nilin.opex.api.app.config + +import org.slf4j.LoggerFactory +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 CorsConfig( + @Value("\${app.cors.enabled:false}") + private val enabled: Boolean, + + @Value("\${app.cors.allowed-origins}") + private val allowedOrigins: String +) { + private val logger = LoggerFactory.getLogger(CorsConfig::class.java) + + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + fun corsWebFilter(): CorsWebFilter { + val config = CorsConfiguration().apply { + allowedOrigins = if (enabled) { + this@CorsConfig.allowedOrigins + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + } else { + emptyList() + } + allowedOrigins?.forEach { + logger.info("Allowed origin: {}", it) + } + 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/InitializeService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/InitializeService.kt index c390d73a1..b28ce1e11 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/InitializeService.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/InitializeService.kt @@ -1,28 +1,16 @@ package co.nilin.opex.api.app.config -import co.nilin.opex.api.ports.postgres.dao.SymbolMapRepository -import co.nilin.opex.api.ports.postgres.model.SymbolMapModel -import co.nilin.opex.utility.preferences.Preferences import jakarta.annotation.PostConstruct -import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.runBlocking -import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.DependsOn import org.springframework.stereotype.Component @Component @DependsOn("postgresConfig") -class InitializeService(private val symbolMapRepository: SymbolMapRepository) { - - @Autowired - private lateinit var preferences: Preferences +class InitializeService { @PostConstruct fun init() = runBlocking { - preferences.markets.map { - val pair = it.pair ?: "${it.leftSide}_${it.rightSide}" - val items = it.aliases.map { a -> SymbolMapModel(null, pair, a.key, a.alias) } - runCatching { symbolMapRepository.saveAll(items).collectList().awaitSingleOrNull() } - } + // Add symbol maps } } 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..26d82ad6d --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/OpenApiConfig.kt @@ -0,0 +1,51 @@ +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( + @Value("\${app.openapi.server-url:}") + private val serverUrl: String +) { + + @Bean + fun opexOpenApi(): OpenAPI { + val openApi= 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") + ) + ) + if (serverUrl.isNotBlank()) { + openApi.servers( + listOf( + Server() + .url(serverUrl) + .description("Public API server") + ) + ) + } + return openApi + } +} + 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/RateLimitConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt new file mode 100644 index 000000000..fd9167451 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt @@ -0,0 +1,89 @@ +package co.nilin.opex.api.app.config + +import co.nilin.opex.api.app.service.RateLimitCoordinatorService +import co.nilin.opex.api.core.spi.RateLimitConfigService +import co.nilin.opex.common.OpexError +import org.slf4j.LoggerFactory +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import org.springframework.web.util.pattern.PathPatternParser +import reactor.core.publisher.Mono + +@Component +class RateLimitConfig( + private val rateLimitConfig: RateLimitConfigService, + private val coordinator: RateLimitCoordinatorService + +) : WebFilter { + private val logger = LoggerFactory.getLogger(RateLimitConfig::class.java) + private val parser = PathPatternParser() + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + + val endpoint = rateLimitConfig.getEndpoints() + .asSequence() + .filter { it.enabled } + .filter { it.method.equals(exchange.request.method.name(), true) } + .sortedByDescending { it.priority } + .firstOrNull { endpoint -> + val pattern = parser.parse(endpoint.url) + pattern.matches(exchange.request.path) + } + + if (endpoint == null) { + return chain.filter(exchange) + } + + return applyRateLimitIfAuthenticated( + exchange, + chain, + endpoint.groupId + ) + } + + private fun applyRateLimitIfAuthenticated( + exchange: ServerWebExchange, + chain: WebFilterChain, + groupId: Long + ): Mono { + + return ReactiveSecurityContextHolder.getContext() + .mapNotNull { it.authentication } + .filter { it.isAuthenticated } + .flatMap { auth -> + applyRateLimit(auth.name, exchange, chain, groupId) + } + .switchIfEmpty( + chain.filter(exchange) + ) + } + + private fun applyRateLimit( + identity: String, + exchange: ServerWebExchange, + chain: WebFilterChain, + groupId: Long + ): Mono { + + val group = rateLimitConfig.getGroup(groupId) + ?: return chain.filter(exchange) + + val result = coordinator.check( + identity = identity, + groupId = groupId, + maxRequests = group.requestCount, + windowSeconds = group.requestWindowSeconds, + apiPath = exchange.request.uri.path, + apiMethod = exchange.request.method.name() + ) + + return if (result.blocked) { + throw OpexError.RateLimit.exception() + } else { + chain.filter(exchange) + } + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfigLoader.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfigLoader.kt new file mode 100644 index 000000000..447bbeb4d --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfigLoader.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.api.app.config + +import co.nilin.opex.api.core.spi.RateLimitConfigService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class RateLimitConfigLoader( + private val rateLimitConfig: RateLimitConfigService +) { + @EventListener(ApplicationReadyEvent::class) + fun preload() { + CoroutineScope(Dispatchers.Default).launch { + rateLimitConfig.loadConfig() + } + } +} \ 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/config/WebClientConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/WebClientConfig.kt new file mode 100644 index 000000000..a89032080 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/WebClientConfig.kt @@ -0,0 +1,98 @@ +package co.nilin.opex.api.app.config + +import co.nilin.opex.common.utils.LanguageUtils.getDefaultUserLanguage +import io.netty.channel.ChannelOption +import io.netty.handler.logging.LogLevel +import org.springframework.beans.factory.annotation.Value +import org.springframework.cloud.client.ServiceInstance +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer +import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.ClientRequest +import org.springframework.web.reactive.function.client.ExchangeFilterFunction +import org.springframework.web.reactive.function.client.WebClient +import org.zalando.logbook.Logbook +import org.zalando.logbook.netty.LogbookClientHandler +import reactor.core.publisher.Mono +import reactor.netty.http.client.HttpClient +import reactor.netty.resources.ConnectionProvider +import reactor.netty.transport.logging.AdvancedByteBufFormat +import java.time.Duration + +@Configuration +class WebClientConfig( + private val logbook: Logbook, + @Value("\${app.auth.url}") + private val url: String, +) { + private val provider = ConnectionProvider.builder("apiPool") + .maxConnections(150) + .pendingAcquireMaxCount(100) + .maxIdleTime(Duration.ofSeconds(30)) + .maxLifeTime(Duration.ofMinutes(2)) + .pendingAcquireTimeout(Duration.ofSeconds(10)) + .evictInBackground(Duration.ofMinutes(1)) + .build() + + private val client = HttpClient.create(provider) + .wiretap("reactor.netty.http.client.HttpClient", LogLevel.DEBUG, AdvancedByteBufFormat.SIMPLE) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .responseTimeout(Duration.ofSeconds(30)) + .keepAlive(true) + .doOnConnected { it.addHandlerLast(LogbookClientHandler(logbook)) } + + + @Bean("generalWebClient") + fun loadBalancedWebClient( + loadBalancerFactory: ReactiveLoadBalancer.Factory, + + ): WebClient { + return WebClient.builder() + .filter(ReactorLoadBalancerExchangeFilterFunction(loadBalancerFactory, emptyList())) + .clientConnector(ReactorClientHttpConnector(client)) + .filter(languageFilter()) + .codecs { configurer -> + configurer.defaultCodecs() + .maxInMemorySize(5 * 1024 * 1024) + } + .build() + } + + @Bean("keycloakWebClient") + fun keycloakWebClient(logbook: Logbook): WebClient { + val provider = ConnectionProvider.builder("keycloakPool") + .maxConnections(100) + .maxIdleTime(Duration.ofSeconds(30)) + .maxLifeTime(Duration.ofMinutes(2)) + .pendingAcquireTimeout(Duration.ofSeconds(60)) + .evictInBackground(Duration.ofMinutes(1)) + .build() + + val client = HttpClient.create(provider) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) + .responseTimeout(Duration.ofSeconds(10)) + .keepAlive(true) + .doOnConnected { it.addHandlerLast(LogbookClientHandler(logbook)) } + + client.warmup().block() + + return WebClient.builder() + .clientConnector(ReactorClientHttpConnector(client)) + .baseUrl(url) + .build() + } + + private fun languageFilter() = ExchangeFilterFunction { request, next -> + Mono.deferContextual { ctx -> + val lang = ctx.getOrDefault("lang", getDefaultUserLanguage()) + val mutatedRequest = ClientRequest.from(request) + .header("Accept-Language", lang) + .build() + next.exchange(mutatedRequest) + } + } + + +} \ No newline at end of file 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 ab8bcd624..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 @@ -1,64 +1,339 @@ package co.nilin.opex.api.app.controller -import co.nilin.opex.api.app.data.APIKeyResponse -import co.nilin.opex.api.app.data.CreateAPIKeyRequest -import co.nilin.opex.api.app.service.APIKeyServiceImpl -import co.nilin.opex.api.ports.binance.util.jwtAuthentication -import co.nilin.opex.api.ports.binance.util.tokenValue -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.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 -import java.security.Principal +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") -class APIKeyController(private val apiKeyService: APIKeyServiceImpl) { +@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() - @GetMapping - suspend fun getKeys(principal: Principal): List { - return apiKeyService.getKeysByUserId(principal.name) - .map { APIKeyResponse(it.label, it.expirationTime, it.allowedIPs, it.key, it.isEnabled) } + private fun generateSecretBase64(bytes: Int = 48): String { + val b = ByteArray(bytes) + rng.nextBytes(b) + return Base64.getEncoder().encodeToString(b) } + private fun canonicalTemplate(): String = "METHOD\nPATH\nQUERY\nBODY_SHA256\nTIMESTAMP_MS" + + private fun headersTemplate(apiKeyId: String): Map = mapOf( + "X-API-KEY" to apiKeyId, + "X-API-SIGNATURE" to "Base64(HMAC-SHA256(secret, canonical))", + "X-API-TIMESTAMP" to "", + "X-API-BODY-SHA256" to " (optional)" + ) + @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( - @RequestBody request: CreateAPIKeyRequest, - @CurrentSecurityContext securityContext: SecurityContext - ): Any { - val jwt = securityContext.jwtAuthentication() - val response = apiKeyService.createAPIKey( - jwt.name, - request.label, - request.expiration?.getLocalDateTime(), - request.allowedIPs, - jwt.tokenValue() + @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( + apiKeyId = apiKeyId, + label = req.label, + plaintextSecret = secret, + allowedIps = req.allowedIps, + allowedEndpoints = req.allowedEndpoints, + keycloakUserId = userId, + keycloakUsername = preferredUsername, + enabled = true + ) + return ApiKeyResponse( + apiKeyId = apiKeyId, + label = stored.record.label, + enabled = stored.record.enabled, + allowedIps = stored.record.allowedIps, + allowedEndpoints = stored.record.allowedEndpoints, + keycloakUsername = stored.record.keycloakUsername, + secret = secret ) - return object { - val apiKey = response.second.key - val secret = response.first - } } - @PutMapping("/{key}/enable") - suspend fun enableKey(principal: Principal, @PathVariable key: String) { - apiKeyService.changeKeyState(principal.name, key, true) + private fun parseJwtUser(token: String): Pair { + val payload = JwtUtils.decodePayload(token) + val sub = payload["sub"] as? String + val preferred = payload["username"] as? String + require(!sub.isNullOrBlank()) { "JWT missing sub" } + return Pair(sub!!, preferred) + } + + @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, + label = it.label, + enabled = it.enabled, + allowedIps = it.allowedIps, + allowedEndpoints = it.allowedEndpoints, + keycloakUsername = it.keycloakUsername, + secret = null + ) + }.toList() + + @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( + apiKeyId = it.apiKeyId, + label = it.label, + enabled = it.enabled, + allowedIps = it.allowedIps, + allowedEndpoints = it.allowedEndpoints, + keycloakUsername = it.keycloakUsername, + secret = null + ) } - @PutMapping("/{key}/disable") - suspend fun disableKey(principal: Principal, @PathVariable key: String) { - apiKeyService.changeKeyState(principal.name, key, false) + @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) + return ApiKeyResponse( + apiKeyId = stored.record.apiKeyId, + label = stored.record.label, + enabled = stored.record.enabled, + allowedIps = stored.record.allowedIps, + allowedEndpoints = stored.record.allowedEndpoints, + keycloakUsername = stored.record.keycloakUserId, + secret = newSecret + ) } - @DeleteMapping("/{key}") - suspend fun deleteKey(principal: Principal, @PathVariable key: String) { - apiKeyService.deleteKey(principal.name, key) + @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, + label = req.label, + enabled = req.enabled, + allowedIps = req.allowedIps, + allowedEndpoints = req.allowedEndpoints, + keycloakUsername = req.keycloakUsername + ) + return ApiKeyResponse( + apiKeyId = s.apiKeyId, + label = s.label, + enabled = s.enabled, + allowedIps = s.allowedIps, + allowedEndpoints = s.allowedEndpoints, + keycloakUsername = s.keycloakUserId + ) } -} \ No newline at end of file + @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) + } +} 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 new file mode 100644 index 000000000..b029997e1 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt @@ -0,0 +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, +) { + + @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() + } +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt index dfe733f83..21da2279f 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt @@ -1,12 +1,13 @@ package co.nilin.opex.api.app.data import java.time.LocalDateTime -import java.util.* -data class APIKeyResponse( - val label: String, - val expirationTime: LocalDateTime?, - val allowedIPs: String?, - val key: String, - val enabled: Boolean +data class ApiKeyResponse( + val apiKeyId: String, + val label: String?, + val enabled: Boolean, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String?, + val secret: String? = null, // only present on create/rotate ) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt index e0846aa41..7fe91e449 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt @@ -2,6 +2,6 @@ package co.nilin.opex.api.app.data data class AccessTokenResponse( val access_token: String, - val refresh_token: String, + val refresh_token: String?, val expires_in: Long ) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/BlockResult.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/BlockResult.kt new file mode 100644 index 000000000..a84dd5e73 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/BlockResult.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.app.data + +data class BlockResult( + val blocked: Boolean, + val retryAfterSeconds: Int = 0 +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt index e84994737..20ca10712 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt @@ -1,7 +1,9 @@ package co.nilin.opex.api.app.data -data class CreateAPIKeyRequest( - val label: String, - val expiration: APIKeyExpiration?, - val allowedIPs: String? +data class CreateApiKeyRequest( + val apiKeyId: String?, + val label: String?, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String? ) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitPenaltyState.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitPenaltyState.kt new file mode 100644 index 000000000..e7d1f5f94 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitPenaltyState.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.app.data + +data class RateLimitPenaltyState( + var violationCount: Int = 0, + var lastViolationAt: Long? = null, + var bannedUntil: Long? = null +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitState.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitState.kt new file mode 100644 index 000000000..15172b495 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitState.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.app.data + +data class RateLimitState( + var violationCount: Int = 0, + var blockedUntil: Long? = null, + var lastViolationAt: Long? = null, + var graceRemaining: Int = 0 +) diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt new file mode 100644 index 000000000..52bfcfbbc --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.app.data + + + +data class UpdateApiKeyRequest( + val label: String?, + val enabled: Boolean?, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String? +) + + diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/impl/ManualRateLimiterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/impl/ManualRateLimiterImpl.kt new file mode 100644 index 000000000..40d6b9f0d --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/impl/ManualRateLimiterImpl.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.api.app.impl + +import co.nilin.opex.api.app.service.RateLimitCoordinatorService +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import co.nilin.opex.api.core.spi.ManualRateLimiterService +import co.nilin.opex.api.core.spi.RateLimitConfigService +import co.nilin.opex.common.OpexError +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange + +@Component +class ManualRateLimiterImpl( + private val rateLimitConfig: RateLimitConfigService, + private val coordinator: RateLimitCoordinatorService +) : ManualRateLimiterService { + + override fun check( + identity: String, + group: ManualRateLimitGroupType, + exchange: ServerWebExchange + ) { + val group = rateLimitConfig.getGroup(group.name) ?: return + val result = coordinator.check( + identity = identity, + groupId = group.id!!, + maxRequests = group.requestCount, + windowSeconds = group.requestWindowSeconds, + apiPath = exchange.request.uri.path, + apiMethod = exchange.request.method.name() + ) + if (result.blocked) { + throw OpexError.RateLimit.exception() + } + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt index f015a0ffd..6b505346d 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt @@ -1,8 +1,13 @@ package co.nilin.opex.api.app.interceptor -import co.nilin.opex.api.app.service.APIKeyServiceImpl +import co.nilin.opex.api.app.security.ClientCredentialsTokenService +import co.nilin.opex.api.app.security.HmacVerifier +import co.nilin.opex.api.core.spi.APIKeyService import co.nilin.opex.api.core.spi.APIKeyFilter -import kotlinx.coroutines.runBlocking +import co.nilin.opex.common.OpexError +import kotlinx.coroutines.reactor.mono +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.server.ServerWebExchange import org.springframework.web.server.WebFilter @@ -10,25 +15,118 @@ import org.springframework.web.server.WebFilterChain import reactor.core.publisher.Mono @Component -class APIKeyFilterImpl(private val apiKeyService: APIKeyServiceImpl) : APIKeyFilter, WebFilter { +class APIKeyFilterImpl( + private val apiKeyService: APIKeyService, + private val hmacVerifier: HmacVerifier, + private val clientTokenService: ClientCredentialsTokenService +) : APIKeyFilter, WebFilter { + + private val logger = LoggerFactory.getLogger(APIKeyFilterImpl::class.java) override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { val request = exchange.request - val key = request.headers["X-API-KEY"] - if (!key.isNullOrEmpty()) { - val secret = request.headers["X-API-SECRET"] - if (secret.isNullOrEmpty()) - return chain.filter(exchange) - val apiKey = runBlocking { apiKeyService.getAPIKey(key[0], secret[0]) } - if (apiKey != null && apiKey.isEnabled && apiKey.accessToken != null && !apiKey.isExpired) { - val req = exchange.request.mutate() - .header("Authorization", "Bearer ${apiKey.accessToken}") - .build() - return chain.filter(exchange.mutate().request(req).build()) + val apiKeyId = request.headers["X-API-KEY"]?.firstOrNull() + val signature = request.headers["X-API-SIGNATURE"]?.firstOrNull() + val tsHeader = request.headers["X-API-TIMESTAMP"]?.firstOrNull() + val uri = request.uri + val path = "/api" + uri.rawPath + + // HMAC path when signature present + if (!apiKeyId.isNullOrBlank() && !signature.isNullOrBlank() && !tsHeader.isNullOrBlank()) { + return mono { + val entry = apiKeyService.getApiKeyForVerification(apiKeyId) + if (entry == null || !entry.enabled) { + logger.warn("Unknown or disabled API key: {}", apiKeyId) + null + } else { + // Optional IP allowlist + val sourceIp = request.remoteAddress?.address?.hostAddress + if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) { + logger.warn("API key {} request from disallowed IP {}", apiKeyId, sourceIp) + throw OpexError.Forbidden.exception() + } + if (!entry.allowedEndpoints.isNullOrEmpty() && ( !entry.allowedEndpoints!!.contains(path))) { + logger.warn("API key {} request to unauthorized resource {}", apiKeyId, path) + throw OpexError.Forbidden.exception() + } else { + val ts = tsHeader.toLongOrNull() + val bodyHash = request.headers["X-API-BODY-SHA256"]?.firstOrNull() + if (ts == null) { + logger.warn("Invalid timestamp header for bot {}", apiKeyId) + throw OpexError.InvalidTime.exception() + } else { + val ok = hmacVerifier.verify( + entry.secret, + signature, + HmacVerifier.VerificationInput( + method = request.method.name(), + path = path, + query = uri.rawQuery, + timestampMillis = ts, + bodySha256 = bodyHash + ) + ) + if (!ok) { + logger.warn("Invalid signature for apiKey {}", apiKeyId) + throw OpexError.InvalidSignature.exception() + } else { + val userId = entry.keycloakUserId + if (userId.isNullOrBlank()) { + logger.warn("API key {} has no mapped Keycloak userId; rejecting", apiKeyId) + throw OpexError.UnAuthorized.exception() + } else { + val bearer = clientTokenService.exchangeToUserToken(userId) + val req = request.mutate() + .header("Authorization", "Bearer $bearer") + .build() + exchange.mutate().request(req).build() + } + } + } + } + } + }.flatMap { updatedExchange -> + if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange) } } - return chain.filter(exchange) - } + // Secret-only path with X-API-SECRET (kept as requested). We validate the provided secret + // against the stored HMAC secret for the apiKey, then proceed to exchange to the mapped user token. + val legacySecret = request.headers["X-API-SECRET"]?.firstOrNull() + if (apiKeyId.isNullOrBlank() || legacySecret.isNullOrBlank()) { + return chain.filter(exchange) + } + return mono { + val entry = apiKeyService.getApiKeyForVerification(apiKeyId) + if (entry == null || !entry.enabled) { + logger.warn("Unknown or disabled API key on secret path: {}", apiKeyId) + null + } else { + // Optional IP allowlist + val sourceIp = request.remoteAddress?.address?.hostAddress + if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) { + logger.warn("API key {} request from disallowed IP {} (secret path)", apiKeyId, sourceIp) + null + } else if (legacySecret != entry.secret) { + logger.warn("Invalid X-API-SECRET for apiKey {}", apiKeyId) + null + } else { + val userId = entry.keycloakUserId + if (userId.isNullOrBlank()) { + logger.warn("API key {} has no mapped Keycloak userId; rejecting (secret path)", apiKeyId) + null + } else { + val bearer = clientTokenService.exchangeToUserToken(userId) + val req = request.mutate() + .header("Authorization", "Bearer $bearer") + .build() + exchange.mutate().request(req).build() + } + } + } + }.flatMap { updatedExchange -> + if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange) + } + } } \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt index 5de6ae557..f39c3301f 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt @@ -3,6 +3,7 @@ package co.nilin.opex.api.app.proxy import co.nilin.opex.api.app.data.AccessTokenResponse import kotlinx.coroutines.reactor.awaitSingle import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.MediaType import org.springframework.stereotype.Component @@ -12,9 +13,10 @@ import org.springframework.web.reactive.function.client.bodyToMono @Component class AuthProxy( - private val client: WebClient, @Value("\${app.auth.token-url}") - private val tokenUrl: String + private val tokenUrl: String, + @Qualifier("keycloakWebClient") + private val client: WebClient ) { private val logger = LoggerFactory.getLogger(AuthProxy::class.java) @@ -55,4 +57,50 @@ class AuthProxy( .bodyToMono() .awaitSingle() } + + suspend fun clientCredentials(clientId: String, clientSecret: String, scope: String? = null): AccessTokenResponse { + val form = BodyInserters.fromFormData("client_id", clientId) + .with("client_secret", clientSecret) + .with("grant_type", "client_credentials") + val body = if (scope.isNullOrBlank()) form else form.with("scope", scope) + + logger.info("Request client_credentials token for client {}", clientId) + return client.post() + .uri(tokenUrl) + .accept(MediaType.APPLICATION_JSON) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } + + // Exchange a client_credentials access token to a user access token (Token Exchange) + suspend fun exchangeToUser( + clientId: String, + clientSecret: String, + subjectToken: String, + requestedSubjectUserId: String, + audience: String? = null + ): AccessTokenResponse { + val form = BodyInserters.fromFormData("client_id", clientId) + .with("client_secret", clientSecret) + .with("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + .with("subject_token", subjectToken) + .with("requested_subject", requestedSubjectUserId) + .with("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") + val body = if (audience.isNullOrBlank()) form else form.with("audience", audience) + + logger.info("Token exchange to user {} via client {}", requestedSubjectUserId, clientId) + return client.post() + .uri(tokenUrl) + .accept(MediaType.APPLICATION_JSON) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } } \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt new file mode 100644 index 000000000..2a7e8662e --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.app.security + +interface ApiKeyRegistry { + data class BotInfo( + val apiKeyId: String, + val hmacSecret: String, + val enabled: Boolean = true, + val allowedIps: Set? = null, + val keycloakUsername: String? = null + ) + + fun find(apiKeyId: String): BotInfo? +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt new file mode 100644 index 000000000..6d8e72be7 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt @@ -0,0 +1,51 @@ +package co.nilin.opex.api.app.security + +import co.nilin.opex.api.core.spi.ApiKeySecretCrypto +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Component +class ApiKeySecretCryptoImpl( + @Value("\${app.api.crypto.key}") private val base64Key: String +) : ApiKeySecretCrypto { + private val key: SecretKey + private val rng = SecureRandom() + private val logger = LoggerFactory.getLogger(ApiKeySecretCryptoImpl::class.java) + + init { + val decoded = Base64.getDecoder().decode(base64Key) + require(decoded.size == 16 || decoded.size == 24 || decoded.size == 32) { + "app.api.crypto.key must be 128/192/256-bit Base64 key" + } + key = SecretKeySpec(decoded, "AES") + } + + override fun encrypt(plaintext: String): String { + val iv = ByteArray(12) + rng.nextBytes(iv) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val ivB64 = Base64.getEncoder().encodeToString(iv) + val ctB64 = Base64.getEncoder().encodeToString(ct) + return "$ivB64:$ctB64" + } + + override fun decrypt(ciphertext: String): String { + val parts = ciphertext.split(":") + require(parts.size == 2) { "Invalid encrypted secret format" } + val iv = Base64.getDecoder().decode(parts[0]) + val ct = Base64.getDecoder().decode(parts[1]) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + val pt = cipher.doFinal(ct) + return String(pt, Charsets.UTF_8) + } +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt new file mode 100644 index 000000000..648a40099 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt @@ -0,0 +1,53 @@ +package co.nilin.opex.api.app.security + +import co.nilin.opex.api.app.proxy.AuthProxy +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ClientCredentialsTokenService( + private val authProxy: AuthProxy, + @Value("\${app.auth.api-key-client.id}") + private val clientId: String, + @Value("\${app.auth.api-key-client.secret}") + private val clientSecret: String +) { + private val logger = LoggerFactory.getLogger(ClientCredentialsTokenService::class.java) + + private data class CachedToken(val token: String, val expiresAtMillis: Long) + + // Cache for client_credentials token + @Volatile private var cache: CachedToken? = null + + // Cache for exchanged user tokens per subject (exactly like getBearerToken, keyed only by subject) + private val subjectCache = java.util.concurrent.ConcurrentHashMap() + + suspend fun getBearerToken(): String { + val now = Instant.now().toEpochMilli() + val snap = cache + if (snap != null && snap.expiresAtMillis - 30_000 > now) return snap.token + val resp = authProxy.clientCredentials(clientId, clientSecret) + val expiresAt = now + (resp.expires_in * 1000L) + val bearer = resp.access_token + cache = CachedToken(bearer, expiresAt) + logger.debug("Fetched new client_credentials token; expires at {}", expiresAt) + return bearer + } + + // Convenience: exchange cached client token for a user access token, cached per subject + suspend fun exchangeToUserToken(requestedSubjectUserId: String, audience: String? = null): String { + val now = Instant.now().toEpochMilli() + val cached = subjectCache[requestedSubjectUserId] + if (cached != null && cached.expiresAtMillis - 30_000 > now) return cached.token + + val subjectToken = getBearerToken() + val exchanged = authProxy.exchangeToUser(clientId, clientSecret, subjectToken, requestedSubjectUserId, audience) + val token = exchanged.access_token + val expiresAt = now + (exchanged.expires_in * 1000L) + subjectCache[requestedSubjectUserId] = CachedToken(token, expiresAt) + logger.debug("Exchanged and cached user token for subject {}; expires at {}", requestedSubjectUserId, expiresAt) + return token + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt new file mode 100644 index 000000000..97b6a152c --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt @@ -0,0 +1,61 @@ +package co.nilin.opex.api.app.security + +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import java.util.Base64 +import kotlin.math.abs + +@org.springframework.stereotype.Component +class HmacVerifier( + private val allowedSkew: Duration = Duration.ofMinutes(5) +) { + data class VerificationInput( + val method: String, + val path: String, + val timestampMillis: Long, + val bodySha256: String? = null, + val query: String? = null + ) + + fun verify(secret: String, signatureBase64: String, input: VerificationInput): Boolean { + // Check timestamp window + val now = Instant.now().toEpochMilli() + if (abs(now - input.timestampMillis) > allowedSkew.toMillis()) return false + + val canonical = canonicalString(input) + val expected = hmacSha256Base64(secret, canonical) + // Constant-time compare + return constantTimeEquals(signatureBase64, expected) + } + + private fun canonicalString(input: VerificationInput): String { + val sb = StringBuilder() + sb.append(input.method.uppercase()).append('\n') + .append(input.path).append('\n') + .append(input.query ?: "").append('\n') + .append(input.bodySha256 ?: "").append('\n') + .append(input.timestampMillis) + return sb.toString() + } + + private fun hmacSha256Base64(secret: String, data: String): String { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) + val raw = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8)) + return Base64.getEncoder().encodeToString(raw) + } + + private fun constantTimeEquals(a: String, b: String): Boolean { + val aBytes = a.toByteArray(StandardCharsets.UTF_8) + val bBytes = b.toByteArray(StandardCharsets.UTF_8) + var result = aBytes.size xor bBytes.size + val len = minOf(aBytes.size, bBytes.size) + for (i in 0 until len) { + result = result or (aBytes[i].toInt() xor bBytes[i].toInt()) + } + return result == 0 + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt deleted file mode 100644 index 034808af0..000000000 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt +++ /dev/null @@ -1,206 +0,0 @@ -package co.nilin.opex.api.app.service - -import co.nilin.opex.api.app.proxy.AuthProxy -import co.nilin.opex.api.core.inout.APIKey -import co.nilin.opex.api.core.spi.APIKeyService -import co.nilin.opex.api.ports.postgres.dao.APIKeyRepository -import co.nilin.opex.api.ports.postgres.model.APIKeyModel -import co.nilin.opex.common.OpexError -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.reactive.awaitFirstOrElse -import kotlinx.coroutines.reactive.awaitFirstOrNull -import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.cache.Cache -import org.springframework.cache.CacheManager -import org.springframework.stereotype.Service -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.util.* -import java.util.concurrent.TimeUnit -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.math.log - -@Service -class APIKeyServiceImpl( - private val apiKeyRepository: APIKeyRepository, - private val authProxy: AuthProxy, - private val cacheManager: CacheManager, - @Value("\${app.auth.api-key-client.secret}") - private val clientSecret: String -) : APIKeyService { - - private val logger = LoggerFactory.getLogger(APIKeyServiceImpl::class.java) - - override suspend fun createAPIKey( - userId: String, - label: String, - expirationTime: LocalDateTime?, - allowedIPs: String?, - currentToken: String - ): Pair { - if (apiKeyRepository.countByUserId(userId).awaitFirstOrElse { 0 } >= 10) - throw OpexError.APIKeyLimitReached.exception() - - val secret = generateSecret() - val tokenResponse = authProxy.exchangeToken(clientSecret, currentToken) - val apiKey = apiKeyRepository.save( - APIKeyModel( - null, - userId, - label, - encryptAES(tokenResponse.access_token, secret), - encryptAES(tokenResponse.refresh_token, secret), - expirationTime, - allowedIPs, - tokenExpiration(tokenResponse.expires_in) - ) - ).awaitSingle() - - return Pair( - secret, - with(apiKey) { - APIKey(userId, label, accessToken, expirationTime, allowedIPs, key, isEnabled, isExpired) - } - ) - } - - override suspend fun getAPIKey(key: String, secret: String): APIKey? = coroutineScope { - val apiKey = getFromCache(key) - ?: apiKeyRepository.findByKey(key).awaitSingleOrNull()?.apply { putCache(this) } - - with(apiKey) { - if (this != null) { - launch { checkupAPIKey(this@with, secret) } - APIKey( - userId, - label, - decryptAES(accessToken, secret), - expirationTime, - allowedIPs, - key, - isEnabled, - isExpired - ) - } else - null - } - } - - override suspend fun getKeysByUserId(userId: String): List { - return apiKeyRepository.findAllByUserId(userId).collectList().awaitFirstOrElse { emptyList() } - .map { - APIKey( - it.userId, - it.label, - it.accessToken, - it.expirationTime, - it.allowedIPs, - it.key, - it.isEnabled, - it.isExpired - ) - } - } - - override suspend fun changeKeyState(userId: String, key: String, isEnabled: Boolean) { - val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() ?: throw OpexError.NotFound.exception() - if (apiKey.userId != userId) - throw OpexError.Forbidden.exception() - apiKey.isEnabled = isEnabled - apiKeyRepository.save(apiKey).awaitSingle() - } - - override suspend fun deleteKey(userId: String, key: String) { - val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() ?: throw OpexError.NotFound.exception() - if (apiKey.userId != userId) - throw OpexError.Forbidden.exception() - apiKeyRepository.delete(apiKey).awaitFirstOrNull() - } - - private suspend fun checkupAPIKey(apiKey: APIKeyModel, secret: String) { - if (apiKey.isExpired || !apiKey.isEnabled) - return - - try { - val now = LocalDateTime.now() - if (apiKey.expirationTime?.isBefore(now) == true) { - logger.info("Expiring api key ${apiKey.key}") - apiKey.isExpired = true - apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } - logger.info("API key ${apiKey.key} is expired") - return - } - - if (apiKey.tokenExpirationTime.isBefore(now)) { - logger.info("Refreshing api key ${apiKey.key} token") - val response = authProxy.refreshToken(clientSecret, decryptAES(apiKey.refreshToken, secret)) - apiKey.apply { - accessToken = encryptAES(response.access_token, secret) - tokenExpirationTime = tokenExpiration(response.expires_in) - } - apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } - logger.info("API key ${apiKey.key} token refreshed") - } - } catch (e: Exception) { - logger.error("Error checking api key ${apiKey.key}", e) - } - } - - private fun encryptAES(input: String, key: String): String { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { - init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.toByteArray(), "AES"), IvParameterSpec(ByteArray(16))) - } - val cipherText = cipher.doFinal(input.toByteArray()) - return Base64.getEncoder().encodeToString(cipherText) - } - - private fun decryptAES(cipherText: String, key: String): String { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { - init(Cipher.DECRYPT_MODE, SecretKeySpec(key.toByteArray(), "AES"), IvParameterSpec(ByteArray(16))) - } - val plainText = cipher.doFinal(Base64.getDecoder().decode(cipherText)) - return String(plainText) - } - - private fun generateSecret(length: Int = 32): String { - val chars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - return (1..length).map { chars.random() }.joinToString("") - } - - private fun tokenExpiration(expiresInSeconds: Long): LocalDateTime { - val tokenOffsetTime = Date().time + TimeUnit.SECONDS.toMillis(expiresInSeconds) - TimeUnit.MINUTES.toMillis(10) - return LocalDateTime.ofInstant(Instant.ofEpochMilli(tokenOffsetTime), ZoneId.systemDefault()) - } - - private fun getFromCache(key: String): APIKeyModel? { - return getCache()?.get(key)?.get() as APIKeyModel? - } - - private fun putCache(apiKey: APIKeyModel) { - getCache()?.apply { - putIfAbsent(apiKey.key, apiKey) - } - } - - private fun updateCache(apiKey: APIKeyModel) { - getCache()?.apply { - evict(apiKey.key) - put(apiKey.key, apiKey) - } - } - - private fun getCache(): Cache? { - val cache = cacheManager.getCache("apiKey") - if (cache == null) - logger.warn("Could not find cache of apiKey") - return cache - } - -} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/OpexFilterExceptionHandler.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/OpexFilterExceptionHandler.kt new file mode 100644 index 000000000..afc37bcf0 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/OpexFilterExceptionHandler.kt @@ -0,0 +1,36 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.utility.error.data.OpexException +import co.nilin.opex.utility.error.spi.ErrorTranslator +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler +import org.springframework.core.annotation.Order +import org.springframework.http.HttpStatusCode +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +@Component +@Order(-2) +class OpexFilterExceptionHandler( + private val translator: ErrorTranslator, + private val objectMapper: ObjectMapper +) : ErrorWebExceptionHandler { + + override fun handle(exchange: ServerWebExchange, ex: Throwable): Mono { + + if (ex is OpexException) { + return translator.translate(ex).flatMap { error -> + exchange.response.statusCode = HttpStatusCode.valueOf(error.status?.value() ?: 500) + exchange.response.headers.contentType = MediaType.APPLICATION_JSON + + val bytes = objectMapper.writeValueAsBytes(error) + val buffer = exchange.response.bufferFactory().wrap(bytes) + + exchange.response.writeWith(Mono.just(buffer)) + } + } + return Mono.error(ex) + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitCoordinatorService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitCoordinatorService.kt new file mode 100644 index 000000000..61744d114 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitCoordinatorService.kt @@ -0,0 +1,42 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.app.data.BlockResult +import org.springframework.stereotype.Component + +@Component +class RateLimitCoordinatorService( + private val rateLimiterService: RateLimiterService, + private val penaltyService: RateLimitPenaltyService +) { + + + fun check( + identity: String, + groupId: Long, + maxRequests: Int, + windowSeconds: Int, + apiPath: String, + apiMethod: String + ): BlockResult { + + val blocked = penaltyService.isBlocked(identity, apiPath, apiMethod) + if (blocked.blocked) { + return blocked + } + + val allowed = rateLimiterService.checkRateLimit( + identity = identity, + maxRequests = maxRequests, + windowInSeconds = windowSeconds, + apiPath = apiPath, + apiMethod = apiMethod + ) + + return if (allowed) { + penaltyService.onAllowed(identity, groupId, apiPath, apiMethod) + BlockResult(blocked = false) + } else { + penaltyService.onLimit(identity, groupId, apiPath, apiMethod) + } + } +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitPenaltyService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitPenaltyService.kt new file mode 100644 index 000000000..f0d483478 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitPenaltyService.kt @@ -0,0 +1,103 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.app.data.BlockResult +import co.nilin.opex.api.app.data.RateLimitPenaltyState +import co.nilin.opex.api.core.spi.RateLimitConfigService +import co.nilin.opex.api.ports.postgres.util.RedisCacheHelper +import co.nilin.opex.common.utils.DynamicInterval +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.concurrent.TimeUnit +import kotlin.math.min + +@Component +class RateLimitPenaltyService(private val config: RateLimitConfigService, private val redis: RedisCacheHelper) { + + fun isBlocked(identity: String, apiPath: String, apiMethod: String): BlockResult { + val state = getPenaltyState(identity, apiPath, apiMethod) ?: return BlockResult(false) + + val now = System.currentTimeMillis() + val bannedUntil = state.bannedUntil ?: return BlockResult(false) + + return if (bannedUntil > now) { + BlockResult( + blocked = true, + retryAfterSeconds = ((bannedUntil - now) / 1000).toInt() + ) + } else { + BlockResult(false) + } + } + + fun onLimit(identity: String, groupId: Long, apiPath: String, apiMethod: String): BlockResult { + val now = System.currentTimeMillis() + val group = config.getGroup(groupId) ?: return BlockResult(false) + val penalties = config.getPenalties(groupId).sortedBy { it.blockStep } + + val current = getPenaltyState(identity, apiPath, apiMethod) + val nextViolationCount = (current?.violationCount ?: 0) + 1 + + val level = min(nextViolationCount, penalties.size) + val penalty = penalties[level - 1] + + val bannedUntil = now + Duration.ofSeconds(penalty.blockDurationSeconds.toLong()).toMillis() + + val newState = RateLimitPenaltyState( + violationCount = nextViolationCount, + lastViolationAt = now, + bannedUntil = bannedUntil + ) + + val ttl = penalty.blockDurationSeconds + group.cooldownSeconds + + savePenaltyState(identity, apiPath, apiMethod, newState, ttl) + + return BlockResult( + blocked = true, + retryAfterSeconds = penalty.blockDurationSeconds + ) + } + + fun onAllowed(identity: String, groupId: Long, apiPath: String, apiMethod: String) { + val state = getPenaltyState(identity, apiPath, apiMethod) ?: return + val group = config.getGroup(groupId) ?: return + val now = System.currentTimeMillis() + + val lastViolation = state.lastViolationAt ?: return + val cooldownMillis = Duration.ofSeconds(group.cooldownSeconds.toLong()).toMillis() + + if (now - lastViolation >= cooldownMillis && state.violationCount > 0) { + val newState = state.copy( + violationCount = state.violationCount - 1 + ) + savePenaltyState(identity, apiPath, apiMethod, newState, group.cooldownSeconds) + } + } + + private fun getPenaltyState( + identity: String, + apiPath: String, + apiMethod: String + ): RateLimitPenaltyState? { + return redis.get(buildPenaltyStateKey(identity, apiPath, apiMethod)) + } + + private fun savePenaltyState( + identity: String, + apiPath: String, + apiMethod: String, + state: RateLimitPenaltyState, + ttlSeconds: Int + ) { + redis.put( + buildPenaltyStateKey(identity, apiPath, apiMethod), + state, + DynamicInterval(ttlSeconds, TimeUnit.SECONDS) + ) + } + + private fun buildPenaltyStateKey(identity: String, apiPath: String, apiMethod: String): String { + val key = "$identity:$apiMethod:$apiPath" + return "rl:penalty:${key.hashCode()}" + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimiterService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimiterService.kt new file mode 100644 index 000000000..b39431cbd --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimiterService.kt @@ -0,0 +1,53 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.ports.postgres.util.RedisCacheHelper +import co.nilin.opex.common.utils.DynamicInterval +import io.github.bucket4j.Bandwidth +import io.github.bucket4j.Bucket +import io.github.bucket4j.Refill +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.concurrent.TimeUnit + + +@Component +class RateLimiterService( + private val redisCacheHelper: RedisCacheHelper +) { + + private val buckets: MutableMap = mutableMapOf() + + private fun createBucket(maxRequests: Int, windowInSeconds: Long): Bucket { + val limit = Bandwidth.classic( + maxRequests.toLong(), + Refill.greedy(maxRequests.toLong(), Duration.ofSeconds(windowInSeconds)) + ) + return Bucket.builder().addLimit(limit).build() + } + + fun checkRateLimit(identity: String, maxRequests: Int, windowInSeconds: Int, apiPath: String, apiMethod : String): Boolean { + val key = "$identity:$apiMethod:$apiPath" + val redisKey = "rl:${key.hashCode()}" + + val storedTokenCount: Long? = redisCacheHelper.get(redisKey) + val bucket = buckets.computeIfAbsent(redisKey) { createBucket(maxRequests, windowInSeconds.toLong()) } + + if (storedTokenCount == null) { + bucket.reset() + redisCacheHelper.put(redisKey, maxRequests.toLong(), DynamicInterval(windowInSeconds, TimeUnit.SECONDS)) + } else { + val tokensToAdd = storedTokenCount - bucket.availableTokens + if (tokensToAdd > 0) { + bucket.addTokens(tokensToAdd) + } + } + + val allowed = bucket.tryConsume(1) + + if (allowed) { + redisCacheHelper.put(redisKey, bucket.availableTokens, DynamicInterval(windowInSeconds, TimeUnit.SECONDS)) + } + + return allowed + } +} \ 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 3833dc8a4..ec0a0b34f 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -10,7 +10,19 @@ spring: url: r2dbc:postgresql://${DB_IP_PORT:localhost}/opex username: ${dbusername:opex} password: ${dbpassword:hiopex} - initialization-mode: always +# initialization-mode: always + pool: + enabled: true + initial-size: 5 + max-size: 20 + max-idle-time: 60s + validation-query: SELECT 1 + datasource: + url: jdbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} + cache: + type: redis cloud: bootstrap: enabled: true @@ -37,6 +49,10 @@ spring: prefer-ip-address: true config: import: vault://secret/${spring.application.name} + data: + redis: + port: 6379 + host: redis-cache management: endpoints: web: @@ -91,9 +107,18 @@ logging: level: co.nilin: INFO org.zalando.logbook: TRACE + reactor.netty.pool: DEBUG + reactor.netty.http.client: DEBUG + org.springframework.web.reactive.function.client: DEBUG + co.nilin.opex.api.ports.proxy.impl: DEBUG + app: + base: + url: ${APP_BASE_URL:api} accountant: url: http://opex-accountant + profile: + url: http://opex-profile matching-gateway: url: http://opex-matching-gateway wallet: @@ -102,12 +127,47 @@ app: url: http://opex-market opex-bc-gateway: url: http://opex-bc-gateway + storage: + url: http://storage + config: + url: http://opex-config + auth-gateway: + url: http://opex-auth-gateway auth: - cert-url: http://opex-auth/auth/realms/opex/protocol/openid-connect/certs - token-url: http://opex-auth/auth/realms/opex/protocol/openid-connect/token + url: http://keycloak:8080 + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} + token-url: http://keycloak:8080/realms/opex/protocol/openid-connect/token api-key-client: secret: ${API_KEY_CLIENT_SECRET} + id: opex-api-key binance: api-url: https://api1.binance.com -swagger: - authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token + user-activity-reference-currency: ${USER_ACTIVITY_REFERENCE_CURRENCY:USDT} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} + api: + crypto: + key: ${api_crypto_key:0e1fd29572ec8c85970d76e3433e96ee} + cors: + enabled: true + allowed-origins: ${ALLOWED_ORIGINS:"http://localhost:8110"} + openapi: + server-url: ${OPEN_API_SERVER_URL} +# --- 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/AddAddressBookItemRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddAddressBookItemRequest.kt new file mode 100644 index 000000000..3135d699d --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddAddressBookItemRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.core.inout + +data class AddAddressBookItemRequest( + var name: String, + var address: String, + var addressType: String, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddBankAccountRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddBankAccountRequest.kt new file mode 100644 index 000000000..1f1ea2235 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddBankAccountRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.core.inout + +data class AddBankAccountRequest( + val name: String? = null, + val cardNumber: String? = null, + val iban: String? = null, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddressBookResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddressBookResponse.kt new file mode 100644 index 000000000..2f9975bc6 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddressBookResponse.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +data class AddressBookResponse( + var id: Long? = null, + var name: String, + var address: String, + var addressType: String, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminDepositHistoryRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminDepositHistoryRequest.kt new file mode 100644 index 000000000..b2489b7b8 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminDepositHistoryRequest.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.api.core.inout + +data class AdminDepositHistoryRequest( + val uuid: String?, + val currency: String?, + val sourceAddress: String?, + val transactionRef: String?, + val startTime: Long? = null, + val endTime: Long? = null, + val status: List? = emptyList(), + val ascendingByTime: Boolean = false, + val limit: Int? = 10, + val offset: Int? = 0, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminOrdersHistoryRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminOrdersHistoryRequest.kt new file mode 100644 index 000000000..b8a68298e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminOrdersHistoryRequest.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.core.inout + +data class AdminOrdersHistoryRequest( + val uuid: String?, + val symbol: String?, + val ouid: String?, + val startTime: Long?, + val endTime: Long?, + val orderType: MatchingOrderType?, + val direction: OrderDirection?, + val limit: Int? = 10, + val offset: Int? = 0, + val ascendingByTime: Boolean? = false, + val includeNames: Boolean? = false +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTradeHistoryRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTradeHistoryRequest.kt new file mode 100644 index 000000000..ca823b09c --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTradeHistoryRequest.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout + +data class AdminTradeHistoryRequest( + val coin: String?, + val startTime: Long? = null, + val endTime: Long? = null, + val limit: Int? = 10, + val offset: Int? = 0, + val ascendingByTime: Boolean = false +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTradesHistoryRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTradesHistoryRequest.kt new file mode 100644 index 000000000..1065682f7 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTradesHistoryRequest.kt @@ -0,0 +1,20 @@ +package co.nilin.opex.api.core.inout + +data class AdminTradesHistoryRequest( + val symbol: String?, + val baseAsset: String?, + val quoteAsset: String?, + val ouid: String?, + val makerOuid: String?, + val takerOuid: String?, + val uuid: String?, + val makerUuid: String?, + val takerUuid: String?, + val fromDate: Long?, + val toDate: Long?, + val ascendingByTime: Boolean? = false, + val excludeSelfTrade: Boolean? = true, + val limit: Int?=10, + val offset: Int?=0, + val includeNames: Boolean? = false, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTransferReserveRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTransferReserveRequest.kt new file mode 100644 index 000000000..f4dd5b78f --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTransferReserveRequest.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class AdminTransferReserveRequest( + val sourceSymbol: String, + val sourceAmount: BigDecimal, + val destSymbol: String, + val destAmount: BigDecimal? = null, + val rate: BigDecimal? = null, + val receiverUuid: String, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminWithdrawHistoryRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminWithdrawHistoryRequest.kt new file mode 100644 index 000000000..7d7a0c073 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminWithdrawHistoryRequest.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.core.inout + +data class AdminWithdrawHistoryRequest( + val uuid: String? = null, + val withdrawUuid: String? = null, + val currency: String?= null, + val destTxRef: String?= null, + val destAddress: String?= null, + val status: List = emptyList(), + val startTime: Long? = null, + val endTime: Long? = null, + val ascendingByTime: Boolean = false, + val limit: Int? = 10, + val offset: Int? = 0, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Amount.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Amount.kt new file mode 100644 index 000000000..8bf4ce101 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Amount.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class Amount(val currency: CurrencyCommand, val amount: BigDecimal) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AssignAddressRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AssignAddressRequest.kt new file mode 100644 index 000000000..79766d975 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AssignAddressRequest.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.api.core.inout + +data class AssignAddressRequest(val uuid: String, val currency: String, val gatewayUuid: String) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AssignedAddress.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AssignedAddress.kt index 29d26702f..d1eba6729 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AssignedAddress.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AssignedAddress.kt @@ -1,5 +1,7 @@ package co.nilin.opex.api.core.inout +import java.time.LocalDateTime + data class AddressType(val id: Long, val type: String, val addressRegex: String, val memoRegex: String?) data class Chain(val name: String, val addressTypes: List) data class AssignedAddress( @@ -7,5 +9,7 @@ data class AssignedAddress( val address: String, val memo: String?, val type: AddressType, - val chains: MutableList + val chains: MutableList, + var expTime: LocalDateTime? = null, + var assignedDate: LocalDateTime? = null, ) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/BankAccountResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/BankAccountResponse.kt new file mode 100644 index 000000000..156a92269 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/BankAccountResponse.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.api.core.inout + + +data class BankAccountResponse( + var id: Long? = null, + var name: String? = null, + var cardNumber: String? = null, + var iban: String? = null, + var accountNumber: String? = null, + var bank: String? = null, + var status: BankAccountStatus, +) + +enum class BankAccountStatus { + WAITING, VERIFIED, REJECTED +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt new file mode 100644 index 000000000..e6c0540a6 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +data class ChainInfo( + val name: String, + val addressTypes: String?, + val externalChainScannerUrl: String? = null, + val addressRegex: String? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CompleteProfileRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CompleteProfileRequest.kt new file mode 100644 index 000000000..f12be7dcc --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CompleteProfileRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout + +data class CompleteProfileRequest( + var firstName: String, + var lastName: String, + var address: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: NationalityType, + var identifier: String, + var gender: Gender, + var birthDate: Long, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ContactUpdateConfirmRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ContactUpdateConfirmRequest.kt new file mode 100644 index 000000000..78059c4b9 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ContactUpdateConfirmRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.core.inout + +data class ContactUpdateConfirmRequest( + val email: String? = null, + val mobile: String? = null, + val otpCode: String, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ContactUpdateRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ContactUpdateRequest.kt new file mode 100644 index 000000000..90a77978d --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ContactUpdateRequest.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +data class ContactUpdateRequest( + val email: String? = null, + val mobile: String? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyCommand.kt new file mode 100644 index 000000000..db3195096 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyCommand.kt @@ -0,0 +1,39 @@ +package co.nilin.opex.api.core.inout + + +import java.math.BigDecimal +import java.util.* + +data class CurrencyCommand( + var symbol: String, + var uuid: String? = UUID.randomUUID().toString(), + var name: String? = null, + var precision: BigDecimal, + var title: String? = null, + var alias: String? = null, + var icon: String? = null, + var isTransitive: Boolean? = false, + var isActive: Boolean? = true, + var sign: String? = null, + var description: String? = null, + var shortDescription: String? = null, + var withdrawAllowed: Boolean? = false, + var depositAllowed: Boolean? = false, + var externalUrl: String? = null, + var gateways: List? = null, + var availableGatewayType: String? = null, + var displayOrder: Int? = null + +) { + fun updateTo(newData: CurrencyCommand): CurrencyCommand { + return newData.apply { + this.uuid = uuid + this.symbol = symbol + } + } + + +} + + +data class CurrenciesCommand(var currencies: List?) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyData.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyData.kt new file mode 100644 index 000000000..89134d287 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyData.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.api.core.inout + + +import java.math.BigDecimal +import java.util.* + +data class CurrencyData( + var symbol: String, + var uuid: String? = UUID.randomUUID().toString(), + var name: String? = null, + var precision: BigDecimal, + var title: String? = null, + var alias: String? = null, + var icon: String? = null, + var isTransitive: Boolean? = false, + var isActive: Boolean? = true, + var sign: String? = null, + var description: String? = null, + var shortDescription: String? = null, + var externalUrl: String? = null, + var displayOrder: Int? = null, + var maxOrder: BigDecimal? = null, + + ) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyLocalizationCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyLocalizationCommand.kt new file mode 100644 index 000000000..91fae9b94 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyLocalizationCommand.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +data class CurrencyLocalizationCommand( + var id: Long? = null, + var name: String? = null, + var title: String? = null, + var alias: String? = null, + var description: String? = null, + var shortDescription: String? = null, + var language: String, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyLocalizationResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyLocalizationResponse.kt new file mode 100644 index 000000000..4bb7bbaa6 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CurrencyLocalizationResponse.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +data class CurrencyLocalizationResponse( + val currency: String, + val currencyLocalizations: List +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositAdminResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositAdminResponse.kt new file mode 100644 index 000000000..3fcbcd6ad --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositAdminResponse.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class DepositAdminResponse( + val id: String, + val uuid: String, + val ownerName: String?, + val currency: String, + val amount: BigDecimal, + val network: String?, + val note: String?, + val transactionRef: String?, + val sourceAddress: String?, + val status: DepositStatus, + val type: DepositType, + val attachment: String?, + val createDate: LocalDateTime, + val transferMethod: TransferMethod? +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositHistoryResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositHistoryResponse.kt new file mode 100644 index 000000000..3889e771a --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositHistoryResponse.kt @@ -0,0 +1,30 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.util.* + +data class DepositHistoryResponse( + val id: String, + val uuid: String, + val currency: String, + val amount: BigDecimal, + val network: String?, + val note: String?, + val transactionRef: String?, + val sourceAddress: String?, + val status: DepositStatus, + val type: DepositType, + val attachment: String?, + val createDate: Date?, + val transferMethod: TransferMethod? +) + +enum class DepositType { + + ON_CHAIN, OFF_CHAIN +} + +enum class DepositStatus { + + PROCESSING, DONE, INVALID +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookHeaders.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookHeaders.kt new file mode 100644 index 000000000..90d628b97 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookHeaders.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +object DepositWebhookHeaders { + const val SIGNATURE = "X-Signature" +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookRequest.kt new file mode 100644 index 000000000..86d5b07a9 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookRequest.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class DepositWebhookRequest( + val referenceNumber: String, + val depositNumber: String, + val symbol: String, + val amount: BigDecimal, + val externalIdentifier: String, + val date: Long +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookResponse.kt new file mode 100644 index 000000000..659cc3d3f --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/DepositWebhookResponse.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.core.inout + +data class DepositWebhookResponse( + val referenceNumber: String? = null, + val status: String, + val duplicate: Boolean = false, + val message: String? = null +) + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/FeeConfig.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/FeeConfig.kt new file mode 100644 index 000000000..e7f29715b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/FeeConfig.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class FeeConfig( + val name: String, + val displayOrder: Int, + val minAssetVolume: BigDecimal, + val maxAssetVolume: BigDecimal? = null, + val minTradeVolume: BigDecimal, + val maxTradeVolume: BigDecimal? = null, + val makerFee: BigDecimal, + val takerFee: BigDecimal, + val condition: Condition +) +enum class Condition { AND, OR } + 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 new file mode 100644 index 000000000..457deb99a --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayCommand.kt @@ -0,0 +1,108 @@ +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.* + + +@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 CurrencyGatewayCommand( + 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 depositDescription: String? = null, + open var withdrawDescription: String? = null, + open var displayOrder: Int? = null, +) + +data class OffChainGatewayCommand( + 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 depositDescription: String? = null, + override var withdrawDescription: String? = null, + override var displayOrder: Int? = null, + + ) : CurrencyGatewayCommand( + currencySymbol, + gatewayUuid, + isDepositActive, + isWithdrawActive, + withdrawFee, + withdrawAllowed, + depositAllowed, + depositMin, + depositMax, + withdrawMin, + withdrawMax, + depositDescription, + withdrawDescription, + displayOrder +) + +data class OnChainGatewayCommand( + + 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 depositDescription: String? = null, + override var withdrawDescription: String? = null, + override var displayOrder: Int? = null, +) : CurrencyGatewayCommand( + currencySymbol, + gatewayUuid, + isDepositActive, + isWithdrawActive, + withdrawFee, + withdrawAllowed, + depositAllowed, + depositMin, + depositMax, + withdrawMin, + withdrawMax, + depositDescription, + withdrawDescription, + displayOrder +) + + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayLocalizationCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayLocalizationCommand.kt new file mode 100644 index 000000000..9c147858b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayLocalizationCommand.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout + + +data class GatewayLocalizationCommand( + var id: Long? = null, + var depositDescription: String? = null, + var withdrawDescription: String? = null, + var language: String +) + + + + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayLocalizationResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayLocalizationResponse.kt new file mode 100644 index 000000000..79e149333 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayLocalizationResponse.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +data class GatewayLocalizationResponse( + val gatewayUuid: String, + val localizations: List +) 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/Gender.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Gender.kt new file mode 100644 index 000000000..04035f4c5 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Gender.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +enum class Gender { + FEMALE, MALE +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/InquiryRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/InquiryRequest.kt new file mode 100644 index 000000000..31afe1413 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/InquiryRequest.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +data class InquiryRequest( + val startTime: Long, + val status: List? = null, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/KycLevel.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/KycLevel.kt new file mode 100644 index 000000000..417268013 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/KycLevel.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +enum class KycLevel { + LEVEL_1, LEVEL_2, LEVEL_3 +} + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualRateLimitGroupType.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualRateLimitGroupType.kt new file mode 100644 index 000000000..110844a65 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualRateLimitGroupType.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +enum class ManualRateLimitGroupType { + VERIFY_OTP, +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualTransferRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualTransferRequest.kt new file mode 100644 index 000000000..431aafb1f --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualTransferRequest.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +data class ManualTransferRequest( + var ref: String, + var description: String? = null, + var attachment: String?, + var gatewayUuid: String +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/MarketBasicData.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/MarketBasicData.kt new file mode 100644 index 000000000..de478609e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/MarketBasicData.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.core.inout + +data class MarketBasicData( + val quoteCurrencies: List, + val referenceCurrencies: List, + val userActivityReferenceCurrency: String +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/NationalityType.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/NationalityType.kt new file mode 100644 index 000000000..a9eb89bec --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/NationalityType.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +enum class NationalityType { + IRANIAN, + NON_IRANIAN +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPReceiver.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPReceiver.kt new file mode 100644 index 000000000..c54e22758 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPReceiver.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +data class OTPReceiver( + val receiver: String, + val type: OTPType, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPType.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPType.kt new file mode 100644 index 000000000..45d19c8e3 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPType.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +enum class OTPType { + + SMS, EMAIL, +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderAdminItem.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderAdminItem.kt new file mode 100644 index 000000000..aec8f5592 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderAdminItem.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + + +data class OrderAdminItem( + val symbol: String, + val ouid: String, + val orderType: MatchingOrderType?, + val side: OrderDirection, + val price: BigDecimal, + val quantity: BigDecimal, + val quoteQuantity: BigDecimal?, + val executedQuantity: BigDecimal?, + val takerFee: BigDecimal?, + val makerFee: BigDecimal?, + val status: OrderStatus?, + val appearance: Int?, + val createDate: LocalDateTime, + val updateDate: LocalDateTime, + val uuid: String?, + val ownerName: String? = null, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderData.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderData.kt new file mode 100644 index 000000000..d10688d1b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderData.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* + +data class OrderData( + val symbol: String, + val orderId: Long, + val orderType: MatchingOrderType, + val side: OrderDirection, + val price: BigDecimal, + val quantity: BigDecimal, + val quoteQuantity: BigDecimal, + val executedQuantity: BigDecimal, + val takerFee: BigDecimal, + val makerFee: BigDecimal, + val status: OrderStatus, + val appearance: Int, + val createDate: LocalDateTime, + val updateDate: LocalDateTime, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderEnums.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderEnums.kt index aba4d3351..bac8c0752 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderEnums.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OrderEnums.kt @@ -6,16 +6,42 @@ enum class TimeInForce { FOK, //Fill or Kill, An order will expire if the full order cannot be filled upon execution. } -enum class OrderStatus(val code: Int) { +enum class OrderStatus(val code: Int, val orderOfAppearance: Int) { - REQUESTED(0), - NEW(1), //The order has been accepted by the engine. - PARTIALLY_FILLED(4), //A part of the order has been filled. - FILLED(5), //The order has been completed. - CANCELED(2), //The order has been canceled by the user. - REJECTED(3), //The order was not accepted by the engine and not processed. - EXPIRED(6); //The order was canceled according to the order type's rules (e.g. LIMIT FOK orders with no fill, LIMIT IOC or MARKET orders that partially fill) or by the exchange, (e.g. orders canceled during liquidation, orders canceled during maintenance) + REQUESTED(0, 0), + NEW(1, 1), //The order has been accepted by the engine. + PARTIALLY_FILLED(4, 2), //A part of the order has been filled. + FILLED(5, 3), //The order has been completed. + CANCELED(2, 3), //The order has been canceled by the user. + REJECTED(3, 3), //The order was not accepted by the engine and not processed. + EXPIRED( + 6, + 3 + ); //The order was canceled according to the order type's rules (e.g. LIMIT FOK orders with no fill, LIMIT IOC or MARKET orders that partially fill) or by the exchange, (e.g. orders canceled during liquidation, orders canceled during maintenance) + fun comesBefore(status: OrderStatus?): Boolean { + if (status == null) + return false + return orderOfAppearance < status.orderOfAppearance + } + + fun comesAfter(status: OrderStatus?): Boolean { + if (status == null) + return false + return orderOfAppearance > status.orderOfAppearance + } + + fun isOpenOrder(): Boolean { + return this == NEW || this == PARTIALLY_FILLED + } + + companion object { + fun fromCode(code: Int?): OrderStatus? { + if (code == null) + return null + return values().find { it.code == code } + } + } fun isWorking(): Boolean { return listOf(NEW, PARTIALLY_FILLED).contains(this) } diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairConfig.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairConfig.kt new file mode 100644 index 000000000..d1741a047 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairConfig.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +class PairConfig( + val pair: String, + val leftSideWalletSymbol: String, + val rightSideWalletSymbol: String, + val leftSideFraction: BigDecimal, + val rightSideFraction: BigDecimal +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairConfigResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairConfigResponse.kt new file mode 100644 index 000000000..da44479aa --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairConfigResponse.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class PairConfigResponse( + val pair: String, + val leftSideWalletSymbol: String, + val rightSideWalletSymbol: String, + val leftSideFraction: BigDecimal, + val rightSideFraction: BigDecimal +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt index f7e3fd12c..d6c00fe0e 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt @@ -4,8 +4,10 @@ import java.math.BigDecimal data class PairInfoResponse( val pair: String, - val leftSideWalletSymbol: String, - val rightSideWalletSymbol: String, - val leftSideFraction: BigDecimal, - val rightSideFraction: BigDecimal + val baseAsset: String, + val quoteAsset: String, + val isAvailable: Boolean, + val minOrder : BigDecimal, + val maxOrder : BigDecimal, + val orderTypes : String, ) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairSetting.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairSetting.kt new file mode 100644 index 000000000..b3d7d5946 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairSetting.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +class PairSetting( + val pair: String, + val isAvailable: Boolean, + val minOrder : BigDecimal, + val maxOrder : BigDecimal, + val orderTypes : String, + val updateDate: LocalDateTime? = null, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Profile.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Profile.kt new file mode 100644 index 000000000..7141dc707 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Profile.kt @@ -0,0 +1,26 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class Profile( + var email: String?, + var userId: String?, + var firstName: String? = null, + var lastName: String? = null, + var address: String? = null, + var mobile: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: NationalityType? = null, + var identifier: String? = null, + var gender: Gender? = null, + var birthDate: LocalDateTime? = null, + var status: ProfileStatus? = null, + var createDate: LocalDateTime? = null, + var lastUpdateDate: LocalDateTime? = null, + var creator: String? = null, + var kycLevel: KycLevel? = null, + var mobileIdentityMatch: Boolean? = null, + var personalIdentityMatch: Boolean? = null + +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalAdminResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalAdminResponse.kt new file mode 100644 index 000000000..9c1700143 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalAdminResponse.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class ProfileApprovalAdminResponse( + var id: Long, + var userId: String, + var status: ProfileApprovalRequestStatus, + var createDate: LocalDateTime, + var updateDate: LocalDateTime? = null, + var updater: String? = null, + var description: String? = null, + var firstName: String? = null, + var lastName: String? = null +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestFilter.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestFilter.kt new file mode 100644 index 000000000..1734ac018 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestFilter.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.inout + +data class ProfileApprovalRequestFilter( + val userId: String?, + val status: ProfileApprovalRequestStatus?, + val createDateFrom: Long?, + val createDateTo: Long?, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, + + ) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestStatus.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestStatus.kt new file mode 100644 index 000000000..cdf56aaac --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestStatus.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +enum class ProfileApprovalRequestStatus { + PENDING, APPROVED, REJECTED +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestUser.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestUser.kt new file mode 100644 index 000000000..1cc8b19f1 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestUser.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class ProfileApprovalRequestUser( + var status: ProfileApprovalRequestStatus, + var createDate: LocalDateTime, + var description: String? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestUserResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestUserResponse.kt new file mode 100644 index 000000000..48af7b292 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileApprovalRequestUserResponse.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.core.inout + +data class ProfileApprovalRequestUserResponse( + var status: ProfileApprovalRequestStatus, + var createDate: Long, + var description: String? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileHistory.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileHistory.kt new file mode 100644 index 000000000..255e7f517 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileHistory.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class ProfileHistory( + var email: String?, + var userId: String?, + var firstName: String? = null, + var lastName: String? = null, + var address: String? = null, + var mobile: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: String? = null, + var identifier: String? = null, + var gender: Boolean? = null, + var birthDate: LocalDateTime? = null, + var status: ProfileStatus? = null, + var createDate: LocalDateTime? = null, + var lastUpdateDate: LocalDateTime? = null, + var creator: String? = null, + var changeRequestDate: LocalDateTime?, + var changeRequestType: String?, + var updatedItem: List?, + var kycLevel: KycLevel? = null, + var mobileIdentityMatch: Boolean? = null, + var personalIdentityMatch: Boolean? = null + +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileRequest.kt new file mode 100644 index 000000000..42be3f18e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileRequest.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.core.inout + +data class ProfileRequest( + var userId: String? = null, + var firstName: String? = null, + var lastName: String? = null, + var mobile: String? = null, + var email: String? = null, + var identifier: String? = null, + var nationality: NationalityType? = null, + var gender: Gender? = null, + var status: ProfileStatus? = null, + var kycLevel: KycLevel? = null, + var createDateFrom: Long? = null, + var createDateTo: Long? = null, + var limit : Int = 10, + var offset: Int = 0, + var ascendingByTime: Boolean = false,) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileResponse.kt new file mode 100644 index 000000000..41d4eab88 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileResponse.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.api.core.inout + +data class ProfileResponse( + var email: String?, + var userId: String?, + var firstName: String? = null, + var lastName: String? = null, + var address: String? = null, + var mobile: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: NationalityType? = null, + var identifier: String? = null, + var gender: Gender? = null, + var birthDate: Long? = null, + var status: ProfileStatus? = null, + var createDate: Long? = null, + var lastUpdateDate: Long? = null, + var creator: String? = null, + var kycLevel: KycLevel? = null, + var mobileIdentityMatch: Boolean? = null, + var personalIdentityMatch: Boolean? = null + +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileStatus.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileStatus.kt new file mode 100644 index 000000000..9e7bb6113 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ProfileStatus.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +enum class ProfileStatus { + CREATED, + CONTACT_INFO_COMPLETED, + PROFILE_COMPLETED, + SYSTEM_APPROVED, + PENDING_ADMIN_APPROVAL, + ADMIN_REJECTED, + ADMIN_APPROVED +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/QuoteCurrency.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/QuoteCurrency.kt new file mode 100644 index 000000000..a306d22c5 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/QuoteCurrency.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class QuoteCurrency( + val currency: String, + val isReference: Boolean = false, + var lastUpdateDate: LocalDateTime = LocalDateTime.now(), + val displayOrder: Int ? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitEndpoint.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitEndpoint.kt new file mode 100644 index 000000000..928a6c805 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitEndpoint.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout + +data class RateLimitEndpoint( + val id: Long? = null, + val url: String, + val method: String, + val groupId: Long, + val priority: Int, + val enabled: Boolean = true +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitGroup.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitGroup.kt new file mode 100644 index 000000000..e9e3db23e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitGroup.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +data class RateLimitGroup( + val id: Long? = null, + val name: String, + val requestCount: Int, + val requestWindowSeconds: Int, + val cooldownSeconds: Int, + val maxPenaltyLevel: Int, + val enabled: Boolean = true +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitPenalty.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitPenalty.kt new file mode 100644 index 000000000..fb078266b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitPenalty.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +data class RateLimitPenalty( + val id: Long? = null, + val groupId: Long, + val blockStep: Int, + val blockDurationSeconds: Int +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RequestDepositBody.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RequestDepositBody.kt new file mode 100644 index 000000000..3168eb16b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RequestDepositBody.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class RequestDepositBody( + val symbol: String, + val receiverUuid: String, + val receiverWalletType: WalletType, + val amount: BigDecimal, + val description: String?, + val transferRef: String?, + val gatewayUuid: String?, + val chain: String?, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RequestWithdrawBody.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RequestWithdrawBody.kt new file mode 100644 index 000000000..766c74c42 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RequestWithdrawBody.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class RequestWithdrawBody( + val currency: String, + val amount: BigDecimal, + val destSymbol: String?, + val destAddress: String, + val destNetwork: String?, + val destNote: String?, + val gatewayUuid: String? +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ReservedTransferResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ReservedTransferResponse.kt new file mode 100644 index 000000000..31ad0bbec --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ReservedTransferResponse.kt @@ -0,0 +1,20 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class ReservedTransferResponse( + var reserveNumber: String, + var sourceSymbol: String, + var destSymbol: String, + var receiverUuid: String, + var sourceAmount: BigDecimal, + var guaranteedDestAmount: BigDecimal, + var reserveDate: LocalDateTime? = LocalDateTime.now(), + var expDate: LocalDateTime? = null, + var status: ReservedStatus? = null +) + + + + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ResolveUsersRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ResolveUsersRequest.kt new file mode 100644 index 000000000..66d781fcc --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ResolveUsersRequest.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +data class ResolveUsersRequest( + val uuids: List +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SubmitVoucherResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SubmitVoucherResponse.kt new file mode 100644 index 000000000..73f2ba3c8 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SubmitVoucherResponse.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class SubmitVoucherResponse( + val amount: BigDecimal, + val currency: String, + var issuer: String?, + var description: String? = null +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SwapAdminResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SwapAdminResponse.kt new file mode 100644 index 000000000..2bddf2b89 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SwapAdminResponse.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class SwapAdminResponse( + var reserveNumber: String, + var sourceSymbol: String, + var destSymbol: String, + var uuid: String, + var ownerName: String?, + var sourceAmount: BigDecimal, + var reservedDestAmount: BigDecimal, + var reserveDate: LocalDateTime? = LocalDateTime.now(), + var expDate: LocalDateTime? = null, + var status: ReservedStatus? = null, + val rate: BigDecimal? = null, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SwapResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SwapResponse.kt new file mode 100644 index 000000000..efea1245f --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/SwapResponse.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class SwapResponse( + var reserveNumber: String, + var sourceSymbol: String, + var destSymbol: String, + var uuid: String, + var sourceAmount: BigDecimal, + var reservedDestAmount: BigDecimal, + var reserveDate: LocalDateTime? = LocalDateTime.now(), + var expDate: LocalDateTime? = null, + var status: ReservedStatus? = null, + val rate: BigDecimal? = null, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TempOtpResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TempOtpResponse.kt new file mode 100644 index 000000000..15ed86cf9 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TempOtpResponse.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.api.core.inout + +data class TempOtpResponse(val otp: String?, var receivers: List? = null) \ No newline at end of file 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 new file mode 100644 index 000000000..5439e9feb --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalCommand.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +data class TerminalCommand( + var uuid: String?, + var owner: String? = null, + var identifier: String, + var active: Boolean? = true, + var metaData: String, + var description: String? = null, + var displayOrder: Int? = null, + ) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalLocalizationCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalLocalizationCommand.kt new file mode 100644 index 000000000..eaba72509 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalLocalizationCommand.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +data class TerminalLocalizationCommand( + var id : Long?, + var description: String?=null, + var owner: String?=null, + var language: String, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalLocalizationResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalLocalizationResponse.kt new file mode 100644 index 000000000..8bcd6f71e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TerminalLocalizationResponse.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +data class TerminalLocalizationResponse( + val terminalUuid: String, + val terminalLocalizations: List +) \ No newline at end of file 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/Trade.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Trade.kt index 6212380ba..31ca765d9 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Trade.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Trade.kt @@ -1,6 +1,7 @@ package co.nilin.opex.api.core.inout import java.math.BigDecimal +import java.time.LocalDateTime import java.util.* data class Trade( @@ -12,7 +13,7 @@ data class Trade( val quoteQuantity: BigDecimal, val commission: BigDecimal, val commissionAsset: String, - val time: Date, + val time: LocalDateTime, val isBuyer: Boolean, val isMaker: Boolean, val isBestMatch: Boolean, diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TradeAdminItem.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TradeAdminItem.kt new file mode 100644 index 000000000..94638023c --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TradeAdminItem.kt @@ -0,0 +1,30 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + + +data class TradeAdminItem( + val tradeId: Long, + val symbol: String, + val baseAsset: String?, + val quoteAsset: String?, + val matchedPrice: BigDecimal, + val matchedQuantity: BigDecimal, + val takerPrice: BigDecimal, + val makerPrice: BigDecimal, + val tradeDate: LocalDateTime, + val makerUuid: String, + val takerUuid: String, + val makerOuid: String?, + val takerOuid: String?, + val makerCommission: BigDecimal?, + val takerCommission: BigDecimal?, + val makerCommissionAsset: String?, + val takerCommissionAsset: String?, + // Enriched in API when includeNames=true + val makerOwnerName: String? = null, + val takerOwnerName: String? = null, +) + + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TradeAdminResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TradeAdminResponse.kt new file mode 100644 index 000000000..9164db6f5 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TradeAdminResponse.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime +@Deprecated("Use TradeResponseItem") +data class TradeAdminResponse( + val id: Long, + val currency: String, + val sourceOwnerUuid: String, + val sourceOwnerName: String?, + val destOwnerUuid: String, + val destOwnerName: String?, + val amount: BigDecimal, + val description: String?, + val ref: String?, + val date: LocalDateTime, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransactionHistoryResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransactionHistoryResponse.kt deleted file mode 100644 index 5b03de954..000000000 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransactionHistoryResponse.kt +++ /dev/null @@ -1,12 +0,0 @@ -package co.nilin.opex.api.core.inout - -import java.math.BigDecimal - -data class TransactionHistoryResponse( - val id: Long, - val currency: String, - val amount: BigDecimal, - val description: String?, - val ref: String?, - val date: Long -) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransactionSummary.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransactionSummary.kt new file mode 100644 index 000000000..d0494d022 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransactionSummary.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class TransactionSummary( + val currency: String, + val amount: BigDecimal, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferCategory.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferCategory.kt new file mode 100644 index 000000000..838b2ba40 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferCategory.kt @@ -0,0 +1,25 @@ +package co.nilin.opex.api.core.inout + +enum class TransferCategory { + + NO_CATEGORY, + DEPOSIT, + DEPOSIT_MANUALLY, + WITHDRAW_REQUEST, + WITHDRAW_ACCEPT, + WITHDRAW_REJECT, + WITHDRAW_CANCEL, + PURCHASE_FINALIZED, + IMPERSONATED_PURCHASE_FINALIZED, + WITHDRAW_MANUALLY, + ORDER_CREATE, + ORDER_CANCEL, + ORDER_FINALIZED, + TRADE, + FEE, + REFERRAL_COMMISSION, + REFERRAL_KYC_REWARD, + REFERENT_COMMISSION, + KYC_ACCEPTED_REWARD, + NORMAL //TODO TEST? +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferMethod.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferMethod.kt new file mode 100644 index 000000000..3cfd6a8a3 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferMethod.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +enum class TransferMethod { + CARD, SHEBA, IPG, EXCHANGE , MANUALLY , VOUCHER, MPG , REWARD, BANK_DEPOSIT, ACCOUNT +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferReserveRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferReserveRequest.kt new file mode 100644 index 000000000..3fb3262e3 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferReserveRequest.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class TransferReserveRequest( + val sourceAmount: BigDecimal, + val sourceSymbol: String, + val destSymbol: String, + var senderUuid: String?, + val receiverUuid: String, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferResult.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferResult.kt new file mode 100644 index 000000000..f6a3657b6 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferResult.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.core.inout + +data class TransferResult( + val date: Long, + val sourceUuid: String, + val sourceWalletType: WalletType, + val sourceBalanceBeforeAction: Amount, + val sourceBalanceAfterAction: Amount, + val amount: Amount, + val destUuid: String, + val destWalletType: WalletType, + val receivedAmount: Amount, + val sourceWallet: Long? = null, + val destWallet: Long? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateApprovalRequestBody.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateApprovalRequestBody.kt new file mode 100644 index 000000000..df0857325 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateApprovalRequestBody.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.core.inout + +data class UpdateApprovalRequestBody( + val id: Long, + val description: String?, + val status: ProfileApprovalRequestStatus +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateUserConfigRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateUserConfigRequest.kt new file mode 100644 index 000000000..a0f57f949 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateUserConfigRequest.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +import co.nilin.opex.common.data.CalenderType +import co.nilin.opex.common.data.Theme +import co.nilin.opex.common.data.UserLanguage + +data class UpdateUserConfigRequest( + val theme: Theme?, + val language: UserLanguage?, + val calender: CalenderType? +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateWebConfigRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateWebConfigRequest.kt new file mode 100644 index 000000000..c855a89b2 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UpdateWebConfigRequest.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.core.inout + +import co.nilin.opex.common.data.CalenderType +import co.nilin.opex.common.data.LanguageOption +import co.nilin.opex.common.data.Theme +import co.nilin.opex.common.data.UserLanguage + +data class UpdateWebConfigRequest( + val logoUrl: String?, + val title: String?, + val description: String?, + var defaultLanguage: UserLanguage?, + var supportedLanguages: Map?, + var supportedCalenders: List?, + val defaultTheme: Theme?, + val supportedThemes: List?, + val supportEmail: String?, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserDetailAssetsSnapshot.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserDetailAssetsSnapshot.kt new file mode 100644 index 000000000..1b66fac00 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserDetailAssetsSnapshot.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class UserDetailAssetsSnapshot( + val uuid: String, + val currencySnapshots: List, + val totalAmount: BigDecimal, + val quoteCurrency: String, + val snapshotDate: LocalDateTime, +) + +data class CurrencyAssetsSnapshot( + val currency: String, + val volume: BigDecimal, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairFeeResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserFee.kt similarity index 54% rename from api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairFeeResponse.kt rename to api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserFee.kt index 580ff3358..83f15dbd4 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairFeeResponse.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserFee.kt @@ -2,10 +2,10 @@ package co.nilin.opex.api.core.inout import java.math.BigDecimal -data class PairFeeResponse( - val pair: String, - val direction: String, - val userLevel: String, +data class UserFee( + val name: String, val makerFee: BigDecimal, val takerFee: BigDecimal -) \ No newline at end of file + ) + + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserLevelConfig.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserLevelConfig.kt new file mode 100644 index 000000000..b6e05b8af --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserLevelConfig.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.api.core.inout + +data class UserLevelConfig( + val userLevel: String, + val language: String, + val name: String, + val description: String? = null, + val permissions: Permissions +) + +data class Permissions( + val onChainDepositAllowed: Boolean? = false, + val offChainDepositAllowed: Boolean? = false, + val onChainWithdrawAllowed: Boolean? = false, + val offChainWithdrawAllowed: Boolean? = false +) \ No newline at end of file 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/UserTransactionCategory.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionCategory.kt new file mode 100644 index 000000000..1e12ff483 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionCategory.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.core.inout + +enum class UserTransactionCategory { + + TRADE, + DEPOSIT, + DEPOSIT_TO, // for admin using DEPOSIT_MANUALLY + WITHDRAW_FROM, // for admin using DEPOSIT_MANUALLY + WITHDRAW, + FEE, + SWAP, + IMPERSONATED_SWAP, + REFERRAL_COMMISSION, + REFERRAL_KYC_REWARD, + REFERENT_COMMISSION, + KYC_ACCEPTED_REWARD, + SYSTEM +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionHistory.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionHistory.kt new file mode 100644 index 000000000..c86873a2f --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionHistory.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class UserTransactionHistory( + val id: String, + val userId: String, + val ownerName: String?, + val currency: String, + val balance: BigDecimal, + val balanceChange: BigDecimal, + val category: UserTransactionCategory, + val description: String?, + val date: LocalDateTime +) 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 new file mode 100644 index 000000000..898d4c420 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionRequest.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.core.inout + +data class UserTransactionRequest( + val userId: String? = null, + val currency: String?, + val sourceSymbol: String?, + val destSymbol: String?, + val category: UserTransactionCategory?, + val startTime: Long? = null, + val endTime: Long? = null, + val limit: Int? = 10, + val offset: Int? = 0, + val ascendingByTime: Boolean = false, +) + diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserWebConfig.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserWebConfig.kt new file mode 100644 index 000000000..c30dc0afe --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserWebConfig.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +import co.nilin.opex.common.data.CalenderType +import co.nilin.opex.common.data.Theme +import co.nilin.opex.common.data.UserLanguage + +data class UserWebConfig( + var theme: Theme, + var language: UserLanguage, + var calender: CalenderType? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletDataResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletDataResponse.kt new file mode 100644 index 000000000..091184fdd --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletDataResponse.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.api.core.inout + +data class WalletDataResponse( + val uuid: String, + val title: String, + val wallets: List +) + +data class WalletCurrencyData( + val currency: String = "", + val free: Double = 0.0, + val locked: Double = 0.0, + val pendingWithdraw: Double = 0.0 +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletTotal.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletTotal.kt new file mode 100644 index 000000000..308dd7391 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletTotal.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +data class WalletTotal( + val currency: String, + val balance: Double +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletType.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletType.kt new file mode 100644 index 000000000..adc7a6b4c --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WalletType.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout + +enum class WalletType { + + MAIN, EXCHANGE, CASHOUT +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawActionResult.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawActionResult.kt new file mode 100644 index 000000000..e51cbafb7 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawActionResult.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +class WithdrawActionResult( + val withdrawId: String, val status: WithdrawStatus, val nextAction: WithdrawNextAction? = null +) + +enum class WithdrawNextAction() { + OTP_EMAIL, + OTP_MOBILE, + WAITING_FOR_ADMIN; +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawAdminResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawAdminResponse.kt new file mode 100644 index 000000000..451db51e2 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawAdminResponse.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +class WithdrawAdminResponse( + val withdrawId: String, + val uuid: String, + val ownerName: String?, + val amount: BigDecimal, + val currency: String, + val appliedFee: BigDecimal, + val destAmount: BigDecimal?, + val destSymbol: String?, + val destAddress: String?, + val destNetwork: String?, + var destNote: String?, + var destTransactionRef: String?, + val statusReason: String?, + val status: WithdrawStatus, + var applicator: String?, + var withdrawType: WithdrawType, + var attachment: String?, + val createDate: LocalDateTime, + val lastUpdateDate: LocalDateTime?, + var transferMethod: TransferMethod?, + val otpRequired: Int?, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawDoneRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawDoneRequest.kt new file mode 100644 index 000000000..8990f55bb --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawDoneRequest.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class WithdrawDoneRequest( + val destTransactionRef: String, + val destNote: String?, + val destAmount: BigDecimal?, + val attachment: String? +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawHistoryResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawHistoryResponse.kt deleted file mode 100644 index 3d0d6607a..000000000 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawHistoryResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -package co.nilin.opex.api.core.inout - -import java.math.BigDecimal - -data class WithdrawHistoryResponse( - val withdrawId: Long?, - val uuid: String, - val amount: BigDecimal, - val currency: String, - val acceptedFee: BigDecimal, - val appliedFee: BigDecimal?, - val destAmount: BigDecimal?, - val destSymbol: String?, - val destAddress: String?, - val destNetwork: String?, - var destNote: String?, - var destTransactionRef: String?, - val statusReason: String?, - val status: String, - val createDate: Long, - val acceptDate: Long? -) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawLimitConfig.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawLimitConfig.kt new file mode 100644 index 000000000..9e15e3316 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawLimitConfig.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class WithdrawLimitConfig( + val name: String, + val dailyMaxAmount: BigDecimal, +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawRejectRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawRejectRequest.kt new file mode 100644 index 000000000..b15b72688 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawRejectRequest.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.api.core.inout + +data class WithdrawRejectRequest(val reason: String, val attachment: String?) 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 new file mode 100644 index 000000000..9e9634efa --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawResponse.kt @@ -0,0 +1,30 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +class WithdrawResponse( + val withdrawId: String, + val uuid: String, + val amount: BigDecimal, + val currency: String, + val appliedFee: BigDecimal, + val destAmount: BigDecimal?, + val destSymbol: String?, + val destAddress: String?, + val destNetwork: String?, + var destNote: String?, + var destTransactionRef: String?, + val statusReason: String?, + val status: WithdrawStatus, + var applicator: String?, + var withdrawType: WithdrawType, + var attachment: String?, + val createDate: LocalDateTime, + val lastUpdateDate: LocalDateTime?, + var transferMethod: TransferMethod?, + val otpRequired: Int? = 0, +) +enum class WithdrawType { + 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/WithdrawStatus.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawStatus.kt new file mode 100644 index 000000000..277f82de7 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/WithdrawStatus.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout + +enum class WithdrawStatus { + REQUESTED, + CREATED, + ACCEPTED, + CANCELED, + REJECTED, + DONE +} \ 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 new file mode 100644 index 000000000..11382715b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.inout.analytics + +import java.math.BigDecimal + + +data class ActivityTotals( + val totalBalance: BigDecimal, + val totalWithdraw: BigDecimal, + val totalDeposit: BigDecimal, + val totalTrade: BigDecimal, + val totalOrder: BigDecimal +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/DailyAmount.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/DailyAmount.kt new file mode 100644 index 000000000..f07212c51 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/DailyAmount.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.inout.analytics + +import java.math.BigDecimal +import java.time.LocalDate + +data class DailyAmount(val date: LocalDate, var totalAmount: BigDecimal) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Attribute.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Attribute.kt new file mode 100644 index 000000000..f04ab4739 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Attribute.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout.auth + +data class Attribute( + val key: String, + val value: String +) + +object Attributes { + + const val EMAIL = "email" + const val MOBILE = "mobile" + const val OTP = "otpConfig" +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Captcha.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Captcha.kt new file mode 100644 index 000000000..c428bcb6e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Captcha.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout.auth + +enum class CaptchaType { + INTERNAL, ARCAPTCHA, HCAPTCHA +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Device.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Device.kt new file mode 100644 index 000000000..7162681b9 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Device.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout.auth + +open class Device { + var deviceUuid: String?=null + var appVersion: String?=null + var osVersion: String?=null + var os: Os?=null +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/OTP.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/OTP.kt new file mode 100644 index 000000000..91d46420e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/OTP.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.api.core.inout.auth + +import co.nilin.opex.api.core.inout.OTPType + +data class OTPReceiver( + val receiver: String, + val type: OTPType, +) + +data class OTPCode( + val code: String, + val otpType: OTPType, +) + +data class OTPVerifyRequest( + val userId: String, + val otpCodes: List +) + +data class OTPVerifyResponse( + val result: Boolean, + val type: OTPResultType +) + +data class TempOtpResponse(val otp: String?, val otpReceiver: OTPReceiver?) + +enum class OTPAction { + REGISTER, FORGET, NONE +} + +enum class OTPResultType { + VALID, EXPIRED, INCORRECT, INVALID +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Session.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Session.kt new file mode 100644 index 000000000..19b255363 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Session.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.api.core.inout.auth + +import java.time.LocalDateTime + +data class Sessions( + val deviceUuid: String?, + val os: Os?, + val osVersion: String?, + val appVersion: String?, + val firstLoginDate: LocalDateTime?, + val lastLoginDate: LocalDateTime?, + val sessionState: String?, + val sessionStatus: SessionStatus?, + val sessionCreateDate: LocalDateTime?, + val sessionExpireDate: LocalDateTime?, + var isCurrentSession: Boolean?=false +) + +enum class SessionStatus { + ACTIVE, + EXPIRED, + TERMINATED +} + +enum class Os { + ANDROID, IOS, MOBILE_WEB, DESKTOP_WEB +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/SessionRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/SessionRequest.kt new file mode 100644 index 000000000..85a001484 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/SessionRequest.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout.auth + +data class SessionRequest( + var uuid: String? = null, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, + val os: Os? = null, + val status: SessionStatus? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Token.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Token.kt new file mode 100644 index 000000000..6a346f78a --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Token.kt @@ -0,0 +1,88 @@ +package co.nilin.opex.api.core.inout.auth + +import co.nilin.opex.api.core.inout.OTPType +import co.nilin.opex.api.core.inout.auth.CaptchaType +import com.fasterxml.jackson.annotation.JsonProperty + +data class PasswordFlowTokenRequest( + val username: String, + val password: String, + val clientId: String, + val clientSecret: String?, + val rememberMe: Boolean = true, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +):Device() + +data class ConfirmPasswordFlowTokenRequest( + val username: String, + val token: String, + val clientId: String, + val clientSecret: String?, + val otp: String, + val rememberMe: Boolean = true, +): Device() + +data class ResendOtpRequest( + val username: String, + val clientId: String +) + + +data class RefreshTokenRequest( + val clientId: String, + val clientSecret: String?, + val refreshToken: String +):Device() + +data class ExternalIdpTokenRequest( + val idToken: String, + val accessToken: String, + val idp: String, + val otpVerifyRequest: OTPVerifyRequest? +):Device() + +data class Token( + @JsonProperty("access_token") + val accessToken: String, // The access token + + @JsonProperty("expires_in") + val expiresIn: Int, // Expiration time of the access token in seconds + + @JsonProperty("refresh_expires_in") + var refreshExpiresIn: Int?, // Expiration time of the refresh token in seconds + + @JsonProperty("refresh_token") + var refreshToken: String?, // The refresh token + + @JsonProperty("token_type") + val tokenType: String?, // Type of token (usually "Bearer") + + @JsonProperty("not-before-policy") + val notBeforePolicy: Int?, // Timestamp indicating when the token becomes valid + + @JsonProperty("session_state") + val sessionState: String?, // Session state (optional) + + @JsonProperty("scope") + val scope: String? // Scopes associated with the token + +) + +data class TokenResponse( + val token: Token?, + val otp: RequiredOTP?, + //TODO IMPORTANT: remove in production + val otpCode: String?, +) + +data class RequiredOTP( + val type: OTPType, + val receiver: String? +) + +data class ResendOtpResponse( + val otp: RequiredOTP?, + //TODO IMPORTANT: remove in production + val otpCode: String?, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/UserRegister.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/UserRegister.kt new file mode 100644 index 000000000..917fcd1fd --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/UserRegister.kt @@ -0,0 +1,66 @@ +package co.nilin.opex.api.core.inout.auth + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +data class RegisterUserRequest( + val username: String, + val firstName: String? = null, + val lastName: String? = null, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class VerifyOTPRequest( + val username: String, + val otp: String, +) + +data class OTPActionTokenResponse( + val token: String, +) + +data class ConfirmRegisterRequest( + val password: String, + val token: String, + val clientId: String?, + val clientSecret: String?, +) : Device() + +data class TokenData( + val isValid: Boolean, + val userId: String, + val action: OTPAction, +) + +data class ExternalIdpUserRegisterRequest( + val idToken: String, + val idp: String, + val password: String, + val otpVerifyRequest: OTPVerifyRequest?, +) + +data class KeycloakUser( + val id: String, + val username: String, + val email: String?, + val firstName: String?, + val lastName: String?, + val emailVerified: Boolean, + val enabled: Boolean, + val attributes: Map>?, +) { + val mobile: String? = attributes?.get(Attributes.MOBILE)?.get(0) +} + +data class ConfirmForgetRequest( + val newPassword: String, + val newPasswordConfirmation: String, + val token: String, +) + +data class ForgotPasswordRequest( + val username: String, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyExchangeRatesResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyExchangeRatesResponse.kt new file mode 100644 index 000000000..f21c1760c --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyExchangeRatesResponse.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout.otc + +import java.math.BigDecimal + +data class CurrencyExchangeRate( + val sourceSymbol: String, + val destSymbol: String, + val rate: BigDecimal, + val isSwappable: Boolean + +) + +data class CurrencyExchangeRatesResponse(val rates: List) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPair.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPair.kt new file mode 100644 index 000000000..bb2ec06bc --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPair.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout.otc + +import co.nilin.opex.common.OpexError + +data class CurrencyPair( + val sourceSymbol: String, + val destSymbol: String +) { + fun validate() { + if (sourceSymbol == destSymbol) + throw OpexError.SourceIsEqualDest.exception() + } +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPrice.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPrice.kt new file mode 100644 index 000000000..ac6536b5e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPrice.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout.otc + +import java.math.BigDecimal + +data class CurrencyPrice(var currency: String, val buyPrice: BigDecimal? = null, var sellPrice: BigDecimal? = null) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/ForbiddenPair.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/ForbiddenPair.kt new file mode 100644 index 000000000..9dbbbf16d --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/ForbiddenPair.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout.otc + +data class ForbiddenPair( + val sourceSymbol: String, + val destinationSymbol: String +) + +data class ForbiddenPairs( + var forbiddenPairs: List? +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Rate.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Rate.kt new file mode 100644 index 000000000..f1b9f3646 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Rate.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout.otc + +import java.math.BigDecimal + +data class Rate( + val sourceSymbol: String, + val destSymbol: String, + val rate: BigDecimal +) + +data class Rates( + var rates: List? +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/SetCurrencyExchangeRateRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/SetCurrencyExchangeRateRequest.kt new file mode 100644 index 000000000..ed2ee64c5 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/SetCurrencyExchangeRateRequest.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.core.inout.otc + +import co.nilin.opex.common.OpexError +import java.math.BigDecimal + +class SetCurrencyExchangeRateRequest( + val sourceSymbol: String, + val destSymbol: String, + val rate: BigDecimal, + var ignoreIfExist: Boolean? = false +) { + fun validate() { + if (rate <= BigDecimal.ZERO) + throw OpexError.InvalidRate.exception() + else if (sourceSymbol == destSymbol) + throw OpexError.SourceIsEqualDest.exception() + } +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Symbols.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Symbols.kt new file mode 100644 index 000000000..2ae155ad7 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Symbols.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.api.core.inout.otc + +data class Symbols(var symbols: List?) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt index f5329c6fa..580e40a0a 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt @@ -1,24 +1,58 @@ package co.nilin.opex.api.core.spi -import co.nilin.opex.api.core.inout.APIKey -import java.time.LocalDateTime - interface APIKeyService { - suspend fun createAPIKey( - userId: String, - label: String, - expirationTime: LocalDateTime?, - allowedIPs: String?, - currentToken: String - ): Pair - - suspend fun getAPIKey(key: String, secret: String): APIKey? - - suspend fun getKeysByUserId(userId: String): List - - suspend fun changeKeyState(userId: String, key: String, isEnabled: Boolean) - - suspend fun deleteKey(userId: String, key: String) - + data class ApiKeyRecord( + val apiKeyId: String, + val label: String?, + val enabled: Boolean, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUserId: String?, + val keycloakUsername: String? + ) + + data class ApiKeyCreateResult( + val secret: String, + val record: ApiKeyRecord + ) + + data class ApiKeyVerification( + val apiKeyId: String, + val secret: String, + val enabled: Boolean, + val allowedEndpoints: Set?, + val allowedIps: Set?, + val keycloakUserId: String? + ) + + suspend fun createApiKeyRecord( + apiKeyId: String, + label: String?, + plaintextSecret: String, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUserId: String?, + keycloakUsername: String?, + enabled: Boolean + ): ApiKeyCreateResult + + suspend fun rotateApiKeySecret(apiKeyId: String, newPlaintextSecret: String): ApiKeyCreateResult + + suspend fun updateApiKeyRecord( + apiKeyId: String, + label: String?, + enabled: Boolean?, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUsername: String? + ): ApiKeyRecord + + suspend fun getApiKeyRecord(apiKeyId: String): ApiKeyRecord? + + suspend fun listApiKeyRecords(): List + + suspend fun deleteApiKeyRecord(apiKeyId: String) + + suspend fun getApiKeyForVerification(apiKeyId: String): ApiKeyVerification? } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AccountantProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AccountantProxy.kt index c6a32e2ab..1497d7a32 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AccountantProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AccountantProxy.kt @@ -1,14 +1,29 @@ package co.nilin.opex.api.core.spi -import co.nilin.opex.api.core.inout.PairFeeResponse -import co.nilin.opex.api.core.inout.PairInfoResponse +import co.nilin.opex.api.core.inout.FeeConfig +import co.nilin.opex.api.core.inout.PairConfigResponse +import co.nilin.opex.api.core.inout.UserFee +import co.nilin.opex.api.core.inout.WithdrawLimitConfig +import co.nilin.opex.api.core.inout.analytics.DailyAmount +import co.nilin.opex.common.utils.Interval +import java.math.BigDecimal interface AccountantProxy { - suspend fun getPairConfigs(): List + suspend fun getPairConfigs(): List - suspend fun getFeeConfigs(): List + suspend fun getFeeConfigs(): List - suspend fun getFeeConfig(symbol: String): PairFeeResponse + suspend fun getUserFee(uuid: String): UserFee + suspend fun getTradeVolumeByCurrency(uuid: String, symbol: String, interval: Interval): BigDecimal + + suspend fun getTotalTradeVolumeValue(uuid: String, interval: Interval): BigDecimal + + suspend fun getWithdrawLimitConfigs(): List + + suspend fun getTotalWithdrawVolumeValue(uuid: String, interval: Interval?): BigDecimal + suspend fun getDailyDepositLast31Days(uuid: String): List + suspend fun getDailyWithdrawLast31Days(uuid: String): List + suspend fun getDailyTradeLast31Days(uuid: String): List } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt new file mode 100644 index 000000000..d5e164cbe --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.spi + +interface ApiKeySecretCrypto { + fun encrypt(plaintext: String): String + fun decrypt(ciphertext: String): String +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt new file mode 100644 index 000000000..fb1d6c3f1 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt @@ -0,0 +1,25 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.auth.* + +interface AuthProxy { + + suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse + suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse + suspend fun resendLoginOtp(request: ResendOtpRequest, token: String): ResendOtpResponse + suspend fun getToken(request: ExternalIdpTokenRequest): TokenResponse + suspend fun refreshToken(request: RefreshTokenRequest): TokenResponse + suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse + suspend fun verifyRegister(request: VerifyOTPRequest): OTPActionTokenResponse + suspend fun confirmRegister(request: ConfirmRegisterRequest): Token + suspend fun registerExternalIdpUser(request: ExternalIdpUserRegisterRequest): TokenResponse + suspend fun forgetPassword(request: ForgotPasswordRequest): TempOtpResponse + suspend fun verifyForget(request: VerifyOTPRequest): OTPActionTokenResponse + suspend fun confirmForget(request: ConfirmForgetRequest) + suspend fun logout(token: String) + suspend fun logout(sessionId: String, token: String) + suspend fun getSessions(request: SessionRequest, token: String): List + suspend fun logoutOthers(token: String) + suspend fun logoutAll(token: String) + +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt index cd99d1064..2929c5f96 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt @@ -1,15 +1,24 @@ package co.nilin.opex.api.core.spi +import co.nilin.opex.api.core.inout.AssignAddressRequest import co.nilin.opex.api.core.inout.AssignResponse -import co.nilin.opex.api.core.inout.CurrencyImplementation +import co.nilin.opex.api.core.inout.ChainInfo import co.nilin.opex.api.core.inout.DepositDetails +import co.nilin.opex.api.core.inout.GatewayLocalizationCommand +import co.nilin.opex.api.core.inout.GatewayLocalizationResponse interface BlockchainGatewayProxy { - suspend fun assignAddress(uuid: String, currency: String, chain: String): AssignResponse? - + suspend fun assignAddress(assignAddressRequest: AssignAddressRequest): AssignResponse? suspend fun getDepositDetails(refs: List): List + suspend fun getChainInfo(): List - suspend fun getCurrencyImplementations(currency: String? = null): List + suspend fun getOnChainGatewayLocalizations(token: String, gatewayUuid: String): GatewayLocalizationResponse + suspend fun saveOnChainGatewayLocalizations( + token: String, + gatewayUuid: String, + gatewayLocalizations: List + ): GatewayLocalizationResponse + suspend fun deleteOnChainGatewayLocalization(token: String, id: Long) } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ConfigProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ConfigProxy.kt new file mode 100644 index 000000000..7d9753344 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ConfigProxy.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.api.core.spi + +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.common.data.WebConfig + +interface ConfigProxy { + suspend fun getWebConfig(): WebConfig + suspend fun updateWebConfig(token: String, request: UpdateWebConfigRequest): WebConfig + suspend fun getUserLevelConfig(): List + suspend fun updateUserLevelConfig(token: String, userLevelConfig: UserLevelConfig): UserLevelConfig + suspend fun deleteUserLevelConfig(token: String, userLevel: String, language: String) + suspend fun getUserConfig(token: String): UserWebConfig + suspend fun updateUserConfig(token: String, request: UpdateUserConfigRequest): UserWebConfig + suspend fun getUserFavoritePair(token: String): Set + suspend fun addUserFavoritePair(token: String, pair: String): Set + suspend fun removeUserFavoritePair(token: String, pair: String): Set + +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ManualRateLimiterService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ManualRateLimiterService.kt new file mode 100644 index 000000000..9858315d1 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ManualRateLimiterService.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import org.springframework.web.server.ServerWebExchange + +interface ManualRateLimiterService { + fun check( + identity: String, + group: ManualRateLimitGroupType, + exchange: ServerWebExchange + ) +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketDataProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketDataProxy.kt index 00b3d2abf..f43b276d6 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketDataProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketDataProxy.kt @@ -2,7 +2,6 @@ package co.nilin.opex.api.core.spi import co.nilin.opex.api.core.inout.* import co.nilin.opex.common.utils.Interval -import java.time.LocalDateTime interface MarketDataProxy { @@ -18,6 +17,10 @@ interface MarketDataProxy { suspend fun recentTrades(symbol: String, limit: Int): List + suspend fun recentTrades(token: String, request: AdminTradesHistoryRequest): List + + suspend fun recentOrders(token: String, request: AdminOrdersHistoryRequest): List + suspend fun lastPrice(symbol: String?): List suspend fun getBestPriceForSymbols(symbols: List): List diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketUserDataProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketUserDataProxy.kt index 7a4e6f59b..c5780e2b0 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketUserDataProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketUserDataProxy.kt @@ -1,18 +1,21 @@ package co.nilin.opex.api.core.spi +import co.nilin.opex.api.core.inout.MatchingOrderType import co.nilin.opex.api.core.inout.Order +import co.nilin.opex.api.core.inout.OrderData +import co.nilin.opex.api.core.inout.OrderDirection import co.nilin.opex.api.core.inout.Trade import java.security.Principal import java.util.* interface MarketUserDataProxy { - suspend fun queryOrder(principal: Principal, symbol: String, orderId: Long?, origClientOrderId: String?): Order? + suspend fun queryOrder(token: String, symbol: String, orderId: Long?, origClientOrderId: String?): Order? - suspend fun openOrders(principal: Principal, symbol: String?, limit: Int?): List + suspend fun openOrders(token: String, symbol: String?, limit: Int?): List suspend fun allOrders( - principal: Principal, + token: String, symbol: String?, startTime: Date?, endTime: Date?, @@ -20,11 +23,49 @@ interface MarketUserDataProxy { ): List suspend fun allTrades( - principal: Principal, + token: String, symbol: String?, fromTrade: Long?, startTime: Date?, endTime: Date?, limit: Int? ): List + + suspend fun getOrderHistory( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List + + suspend fun getOrderHistoryCount( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + ): Long + + suspend fun getTradeHistory( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List + + suspend fun getTradeHistoryCount( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + direction: OrderDirection?, + ): Long } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt index 906aec319..c615e3d3b 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt @@ -4,6 +4,8 @@ import co.nilin.opex.api.core.inout.MatchConstraint import co.nilin.opex.api.core.inout.MatchingOrderType import co.nilin.opex.api.core.inout.OrderDirection import co.nilin.opex.api.core.inout.OrderSubmitResult +import co.nilin.opex.api.core.inout.PairConfigResponse +import co.nilin.opex.api.core.inout.PairSetting import java.math.BigDecimal interface MatchingGatewayProxy { @@ -27,4 +29,6 @@ interface MatchingGatewayProxy { symbol: String, token: String? ): OrderSubmitResult? + + suspend fun getPairSettings(): List } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt new file mode 100644 index 000000000..d5d08aef0 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt @@ -0,0 +1,43 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.CompleteProfileRequest +import co.nilin.opex.api.core.inout.ContactUpdateConfirmRequest +import co.nilin.opex.api.core.inout.ContactUpdateRequest +import co.nilin.opex.api.core.inout.ProfileApprovalRequestUser +import co.nilin.opex.api.core.inout.* + +interface ProfileProxy { + suspend fun getProfile(token: String): Profile + suspend fun completeProfile(token: String, request: CompleteProfileRequest): Profile? + suspend fun requestContactUpdate(token: String, request: ContactUpdateRequest): TempOtpResponse + suspend fun confirmContactUpdate(token: String, request: ContactUpdateConfirmRequest) + suspend fun getUserProfileApprovalRequest(token: String): ProfileApprovalRequestUser + + // Admin + suspend fun getProfiles(token: String, profileRequest: ProfileRequest): List + suspend fun getProfileAdmin(token: String, uuid: String): Profile + suspend fun getProfileHistory(token: String, uuid: String, limit: Int, offset: Int): List + suspend fun getProfileApprovalRequests( + token: String, + request: ProfileApprovalRequestFilter + ): List + + suspend fun getProfileApprovalRequest(token: String, requestId: Long): ProfileApprovalAdminResponse + suspend fun updateProfileApprovalRequest( + token: String, + request: UpdateApprovalRequestBody + ): ProfileApprovalAdminResponse + + suspend fun resolveUsers(token: String, request: ResolveUsersRequest): Map + + // Address Book + suspend fun addAddressBook(token: String, request: AddAddressBookItemRequest): AddressBookResponse + suspend fun getAllAddressBooks(token: String): List + suspend fun deleteAddressBook(token: String, id: Long) + suspend fun updateAddressBook(token: String, id: Long, request: AddAddressBookItemRequest): AddressBookResponse + + //Bank Account + suspend fun addBankAccount(token: String, request: AddBankAccountRequest): BankAccountResponse + suspend fun getBankAccounts(token: String): List + suspend fun deleteBankAccount(token: String, id: Long) +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt new file mode 100644 index 000000000..20971f307 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.RateLimitEndpoint +import co.nilin.opex.api.core.inout.RateLimitGroup +import co.nilin.opex.api.core.inout.RateLimitPenalty + +interface RateLimitConfigService { + suspend fun loadConfig() + fun getGroup(groupId: Long): RateLimitGroup? + fun getGroup(groupName: String): RateLimitGroup? + fun getPenalties(groupId: Long): List + fun getEndpoints(): List +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateProxy.kt new file mode 100644 index 000000000..1be97ce42 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateProxy.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.otc.* + +interface RateProxy { + // Rates (writes require admin token) + suspend fun createRate(token: String, request: SetCurrencyExchangeRateRequest) + suspend fun updateRate(token: String, request: SetCurrencyExchangeRateRequest): Rates + suspend fun deleteRate(token: String, sourceSymbol: String, destSymbol: String): Rates + + // Rates (reads are public) + suspend fun fetchRates(): Rates + suspend fun fetchRate(sourceSymbol: String, destSymbol: String): Rate? + + // Forbidden pairs + suspend fun addForbiddenPair(token: String, request: CurrencyPair) + suspend fun deleteForbiddenPair(token: String, sourceSymbol: String, destSymbol: String): ForbiddenPairs + + // Forbidden pairs (read is public) + suspend fun fetchForbiddenPairs(): ForbiddenPairs + + // Transitive symbols + suspend fun addTransitiveSymbols(token: String, symbols: Symbols) + suspend fun deleteTransitiveSymbol(token: String, symbol: String): Symbols + suspend fun deleteTransitiveSymbols(token: String, symbols: Symbols): Symbols + + // Transitive symbols (read is public) + suspend fun fetchTransitiveSymbols(): Symbols + + // Routes and prices (reads are public) + suspend fun fetchRoutes(sourceSymbol: String? = null, destSymbol: String? = null): CurrencyExchangeRatesResponse + suspend fun getPrice(unit: String): List +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/StorageProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/StorageProxy.kt new file mode 100644 index 000000000..fe29d4f0e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/StorageProxy.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.spi + +import org.springframework.http.ResponseEntity +import org.springframework.http.codec.multipart.FilePart + +interface StorageProxy { + suspend fun adminDownload(token: String, bucket: String, key: String): ResponseEntity + suspend fun adminUpload(token: String, bucket: String, key: String, file: FilePart,isPublic : Boolean? = false) + suspend fun adminDelete(token: String, bucket: String, key: String) + suspend fun publicDownload(bucket: String, key: String): ResponseEntity + +} 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 85a628217..8b0bd2e7c 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 @@ -1,9 +1,8 @@ package co.nilin.opex.api.core.spi -import co.nilin.opex.api.core.inout.OwnerLimitsResponse -import co.nilin.opex.api.core.inout.TransactionHistoryResponse -import co.nilin.opex.api.core.inout.Wallet -import co.nilin.opex.api.core.inout.WithdrawHistoryResponse +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.inout.analytics.DailyAmount +import java.math.BigDecimal interface WalletProxy { @@ -14,26 +13,258 @@ interface WalletProxy { suspend fun getOwnerLimits(uuid: String?, token: String?): OwnerLimitsResponse suspend fun getDepositTransactions( - uuid: String, - token: String?, - coin: String?, - startTime: Long?, - endTime: Long?, - limit: Int, - offset: Int, - ascendingByTime: Boolean? - ): List + uuid: String, + token: String, + currency: String?, + startTime: Long?, + endTime: Long?, + limit: Int, + offset: Int, + ascendingByTime: Boolean?, + ): List + + suspend fun getDepositTransactionsCount( + uuid: String, + token: String, + currency: String?, + startTime: Long?, + endTime: Long?, + ): Long suspend fun getWithdrawTransactions( - uuid: String, - token: String?, - coin: String?, - startTime: Long?, - endTime: Long?, - limit: Int, - offset: Int, - ascendingByTime: Boolean? - ): List + uuid: String, + token: String, + currency: String?, + status: WithdrawStatus?, + startTime: Long?, + endTime: Long?, + limit: Int, + offset: Int, + ascendingByTime: Boolean?, + ): List + + suspend fun getWithdrawTransactionsCount( + uuid: String, + token: String, + currency: String?, + status: WithdrawStatus?, + startTime: Long?, + endTime: Long?, + ): Long + + suspend fun getTransactions( + uuid: String, + token: String, + currency: String?, + category: UserTransactionCategory?, + startTime: Long?, + endTime: Long?, + limit: Int, + offset: Int, + ascendingByTime: Boolean?, + ): List + + suspend fun getTransactionsCount( + uuid: String, + token: String, + currency: String?, + category: UserTransactionCategory?, + startTime: Long?, + endTime: Long?, + ): Long + + suspend fun getGateWays( + includeOffChainGateways: Boolean, + includeOnChainGateways: Boolean, + ): List + + suspend fun getCurrencies(): List + + suspend fun getUserTradeTransactionSummary( + uuid: String, + token: String, + startTime: Long?, + endTime: Long?, + limit: Int?, + ): List + + suspend fun getUserDepositSummary( + uuid: String, + token: String, + startTime: Long?, + endTime: Long?, + limit: Int?, + ): List + + suspend fun getUserWithdrawSummary( + uuid: String, + token: String, + startTime: Long?, + endTime: Long?, + limit: Int?, + ): List + + + suspend fun requestWithdraw( + token: String, + request: RequestWithdrawBody + ): WithdrawActionResult + + suspend fun cancelWithdraw( + token: String, + withdrawUuid: String + ): Void? + + suspend fun findWithdraw( + token: String, + withdrawUuid: String + ): WithdrawResponse + + suspend fun submitVoucher(code: String, token: String): SubmitVoucherResponse + + suspend fun getQuoteCurrencies(): List + + 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( + token: String, + withdrawUuid: String, + otpType: OTPType, + otpCode: String + ): WithdrawActionResult + + suspend fun getWithdrawTransactionsForAdmin( + token: String, + request: AdminWithdrawHistoryRequest + ): List + + suspend fun getDepositTransactionsForAdmin( + token: String, + request: AdminDepositHistoryRequest + ): List + + suspend fun getSwapTransactionsForAdmin( + token: String, + request: UserSwapTransactionRequest + ): List + + suspend fun getTradeHistoryForAdmin( + token: String, + request: AdminTradeHistoryRequest + ): List + + suspend fun getUserTransactionHistoryForAdmin( + token: String, + request: UserTransactionRequest + ): List + + suspend fun getUsersWallets( + token: String, + uuid: String?, + currency: String?, + excludeSystem: Boolean, + limit: Int, + offset: Int + ): List + + suspend fun getSystemWalletsTotal(token: String): List + suspend fun getUsersWalletsTotal(token: String): List + + suspend fun acceptWithdraw(token: String, withdrawUuid: String): WithdrawActionResult + suspend fun doneWithdraw(token: String, withdrawUuid: String, request: WithdrawDoneRequest): WithdrawActionResult + suspend fun rejectWithdraw( + token: String, + withdrawUuid: String, + request: WithdrawRejectRequest + ): WithdrawActionResult + + suspend fun withdrawManually( + token: String, + symbol: String, + sourceUuid: String, + amount: BigDecimal, + request: ManualTransferRequest + ): TransferResult + + suspend fun depositManually( + token: String, + symbol: String, + receiverUuid: String, + amount: BigDecimal, + request: ManualTransferRequest + ): TransferResult + + suspend fun getDailyBalanceLast31Days(token: String, uuid: String): List + + suspend fun reserveSwap(token: String, request: TransferReserveRequest): ReservedTransferResponse + suspend fun finalizeSwap( + token: String, + reserveUuid: String, + description: String?, + transferRef: String? + ): TransferResult + + suspend fun getGatewayTerminal(gatewayUuid: String): List + suspend fun getUsersDetailAssets(limit: Int, offset: Int): List + suspend fun getCurrencyLocalizations(token: String, currency: String): CurrencyLocalizationResponse + suspend fun saveCurrencyLocalizations( + token: String, + currency: String, + currencyLocalizations: List + ): CurrencyLocalizationResponse + + suspend fun deleteCurrencyLocalization(token: String, id: Long) + suspend fun getTerminalLocalizations(token: String, terminalUuid: String): TerminalLocalizationResponse + suspend fun saveTerminalLocalizations( + token: String, + terminalUuid: String, + terminalLocalizations: List + ): TerminalLocalizationResponse + + suspend fun deleteTerminalLocalization(token: String, id: Long) + + suspend fun getOffChainGatewayLocalizations(token: String, gatewayUuid: String): GatewayLocalizationResponse + suspend fun saveOffChainGatewayLocalizations( + token: String, + gatewayUuid: String, + gatewayLocalizations: List + ): GatewayLocalizationResponse + + suspend fun deleteOffChainGatewayLocalization(token: String, id: Long) + suspend fun saveTerminal(token: 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 addGatewayToCurrency( + token: String, + currencySymbol: String, + gatewayCommand: CurrencyGatewayCommand + ): CurrencyGatewayCommand? + + suspend fun updateGateway( + token: String, + gatewayUuid: String, + currencySymbol: String, + gatewayCommand: CurrencyGatewayUpdateCommand + ): CurrencyGatewayCommand? + + suspend fun getGateway(token: String, gatewayUuid: String, currencySymbol: String): CurrencyGatewayCommand? + + suspend fun deleteGateway(token: String, gatewayUuid: String, currencySymbol: String) + suspend fun submitDepositWebhook(request: DepositWebhookRequest, signature: String): DepositWebhookResponse + suspend fun reserveSwapByAdmin(token: String, request: AdminTransferReserveRequest): ReservedTransferResponse + suspend fun finalizeSwapByAdmin( + token: String, + reserveUuid: String, + description: String?, + transferRef: String? + ): TransferResult } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt new file mode 100644 index 000000000..e86b42ad2 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.utils + +fun Set?.toCsv(): String? = this?.joinToString(",") + +fun String?.toSet(): Set? = this + ?.takeIf { it.isNotBlank() } + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toSet() 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/ErrorHandlerConfig.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/ErrorHandlerConfig.kt index bc321e9e0..48154d4a7 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/ErrorHandlerConfig.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/ErrorHandlerConfig.kt @@ -3,7 +3,7 @@ package co.nilin.opex.api.ports.binance.config import co.nilin.opex.utility.error.EnableOpexErrorHandler import org.springframework.context.annotation.Configuration -@Configuration +@Configuration("binanceErrorHandlerConfig") @EnableOpexErrorHandler class ErrorHandlerConfig { diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/RestConfig.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/RestConfig.kt index 1357932d2..3fc377c72 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/RestConfig.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/RestConfig.kt @@ -7,7 +7,7 @@ import org.springframework.format.Formatter import org.springframework.web.server.WebFilter import java.util.* -@Configuration +@Configuration("binanceRestConfig") class RestConfig { @Bean fun dateFormatter(): Formatter? { 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 c818de467..4d005fdb5 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 @@ -1,35 +1,96 @@ package co.nilin.opex.api.ports.binance.config import co.nilin.opex.api.core.spi.APIKeyFilter +import co.nilin.opex.api.ports.binance.util.AudienceValidator +import co.nilin.opex.common.security.ReactiveCustomJwtConverter +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.security.config.Customizer +import org.springframework.context.annotation.Primary +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 import org.springframework.security.config.web.server.ServerHttpSecurity +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.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 @EnableWebFluxSecurity -@Configuration +@Configuration("binanceSecurityConfig") class SecurityConfig( - private val webClient: WebClient, private val apiKeyFilter: APIKeyFilter, @Value("\${app.auth.cert-url}") - private val jwkUrl: String + private val certUrl: String, + @Value("\${app.auth.iss-url}") + private val issUrl: String, + @Qualifier("keycloakWebClient") private val webClient: WebClient, ) { + @Value("\${swagger.auth.enabled:false}") + private var swaggerAuthEnabled: Boolean = false + + @Value("\${swagger.auth.authority:ROLE_admin}") + private lateinit var swaggerAuthority: String @Bean - fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + @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 + @Order(1) + fun preAuthSecurityChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http + .securityMatcher( + ServerWebExchangeMatchers.pathMatchers( + "/opex/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 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() .pathMatchers("/v3/trades").permitAll() @@ -39,19 +100,89 @@ class SecurityConfig( .pathMatchers("/v3/klines").permitAll() .pathMatchers("/socket").permitAll() .pathMatchers("/v1/landing/**").permitAll() - .pathMatchers("/**").hasAuthority("SCOPE_trust") + .pathMatchers(HttpMethod.POST, "/v3/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") + + // Opex endpoints + .pathMatchers("/opex/v1/oauth/protocol/openid-connect/**").permitAll() + .pathMatchers("/opex/v1/oauth.***").permitAll() + .pathMatchers("/opex/v1/user/public/**").permitAll() + .pathMatchers("/opex/v1/user/update/**").permitAll() + .pathMatchers("/v1/deposit/webhook").permitAll() + .pathMatchers("/opex/v1/admin/transactions/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") + .pathMatchers("/opex/v1/storage/**").permitAll() + .pathMatchers("/opex/v1/web/config/**").permitAll() + .pathMatchers("/opex/v1/user-level/config/**").permitAll() + .pathMatchers("/opex/v1/user/config/**").authenticated() + .pathMatchers("/opex/v1/admin/**").hasAuthority("ROLE_admin") + .pathMatchers("/opex/v1/deposit/**").hasAuthority("PERM_deposit:write") + .pathMatchers(HttpMethod.POST, "/opex/v1/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.PUT, "/opex/v1/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.POST, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") + .pathMatchers(HttpMethod.PUT, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") + .pathMatchers("/opex/v1/voucher").hasAuthority("PERM_voucher:submit") + .pathMatchers("/opex/v1/market/**").permitAll() + .pathMatchers("/opex/v1/analytics/users-detail-assets").permitAll() + .pathMatchers(HttpMethod.GET, "/opex/v1/market/chain").permitAll() + .pathMatchers(HttpMethod.POST, "/v1/api-key").authenticated() + .pathMatchers("/v1/api-key").hasAuthority("ROLE_admin") + .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").authenticated() + .pathMatchers("/opex/v1/bank-account/**").hasAuthority("PERM_bank_account:write") .anyExchange().authenticated() } .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) - .oauth2ResourceServer { it.jwt(Customizer.withDefaults()) } + .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } } .build() } @Bean @Throws(Exception::class) + @Primary fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder + } + + @Bean("preAuthJwtDecoder") + @Throws(Exception::class) + fun preAuthJwtDecoder(): ReactiveJwtDecoder? { + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(webClient) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "pre-auth-client", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } + + } diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/WebClientConfig.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/WebClientConfig.kt deleted file mode 100644 index d07ec5c8c..000000000 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/WebClientConfig.kt +++ /dev/null @@ -1,36 +0,0 @@ -package co.nilin.opex.api.ports.binance.config - -import io.netty.channel.ChannelOption -import org.springframework.cloud.client.loadbalancer.LoadBalanced -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient -import reactor.netty.resources.ConnectionProvider -import java.time.Duration - -@Configuration -class WebClientConfig { - - @Bean - @LoadBalanced - fun webClientBuilder(): WebClient.Builder { - return WebClient.builder() - } - - @Bean - fun webClient(webclientBuilder: WebClient.Builder): WebClient { - val cp = ConnectionProvider.builder("apiBinanceWebclientConnectionPool") - .maxConnections(5000) - .maxIdleTime(Duration.ofSeconds(20)) - .maxLifeTime(Duration.ofMinutes(2)) - .pendingAcquireTimeout(Duration.ofSeconds(10)) - .evictInBackground(Duration.ofSeconds(30)) - .build() - val client = HttpClient.create(cp) - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) - .responseTimeout(Duration.ofSeconds(10)) - return webclientBuilder.clientConnector(ReactorClientHttpConnector(client)).build() - } -} 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 f64a43fd2..91224c846 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 @@ -8,10 +8,13 @@ import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.binance.data.* import co.nilin.opex.api.ports.binance.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.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 @@ -22,6 +25,8 @@ import java.time.ZoneId import java.util.* @RestController +@Hidden +@Deprecated("") class AccountController( val queryHandler: MarketUserDataProxy, val matchingGatewayProxy: MatchingGatewayProxy, @@ -146,7 +151,7 @@ class AccountController( if (orderId == null && origClientOrderId == null) throw OpexError.BadRequest.exception("'orderId' or 'origClientOrderId' must be sent") - val order = queryHandler.queryOrder(principal, localSymbol, orderId, origClientOrderId) + val order = queryHandler.queryOrder(securityContext.jwtAuthentication().tokenValue(), localSymbol, orderId, origClientOrderId) ?: throw OpexError.OrderNotFound.exception() val response = CancelOrderResponse( @@ -215,10 +220,11 @@ class AccountController( @RequestParam(required = false) recvWindow: Long?, //The value cannot be greater than 60000 @RequestParam - timestamp: Long - ): QueryOrderResponse { + timestamp: Long, + @CurrentSecurityContext securityContext: SecurityContext, + ): QueryOrderResponse { val internalSymbol = symbolMapper.toInternalSymbol(symbol) ?: throw OpexError.SymbolNotFound.exception() - return queryHandler.queryOrder(principal, internalSymbol, orderId, origClientOrderId) + return queryHandler.queryOrder(securityContext.jwtAuthentication().tokenValue(), internalSymbol, orderId, origClientOrderId) ?.asQueryOrderResponse() ?.apply { this.symbol = symbol } ?: throw OpexError.OrderNotFound.exception() @@ -256,10 +262,11 @@ class AccountController( @RequestParam timestamp: Long, @RequestParam(required = false) - limit: Int? - ): List { + limit: Int?, + @CurrentSecurityContext securityContext: SecurityContext, + ): List { val internalSymbol = symbolMapper.toInternalSymbol(symbol) ?: throw OpexError.SymbolNotFound.exception() - return queryHandler.openOrders(principal, internalSymbol, limit).map { + return queryHandler.openOrders(securityContext.jwtAuthentication().tokenValue(), internalSymbol, limit).map { it.asQueryOrderResponse().apply { symbol?.let { s -> this.symbol = s } } } } @@ -299,10 +306,13 @@ class AccountController( @RequestParam(required = false) recvWindow: Long?, //The value cannot be greater than 60000 @RequestParam - timestamp: Long - ): List { + timestamp: Long, + @CurrentSecurityContext securityContext: SecurityContext, + + + ): List { val internalSymbol = symbolMapper.toInternalSymbol(symbol) ?: throw OpexError.SymbolNotFound.exception() - return queryHandler.allOrders(principal, internalSymbol, startTime, endTime, limit).map { + return queryHandler.allOrders(securityContext.jwtAuthentication().tokenValue(), internalSymbol, startTime, endTime, limit).map { it.asQueryOrderResponse().apply { symbol?.let { s -> this.symbol = s } } } } @@ -346,11 +356,12 @@ class AccountController( @RequestParam(required = false) recvWindow: Long?, //The value cannot be greater than 60000 @RequestParam - timestamp: Long + timestamp: Long, + @CurrentSecurityContext securityContext: SecurityContext, ): List { val internalSymbol = symbolMapper.toInternalSymbol(symbol) ?: throw OpexError.SymbolNotFound.exception() - return queryHandler.allTrades(principal, internalSymbol, fromId, startTime, endTime, limit) + return queryHandler.allTrades(securityContext.jwtAuthentication().tokenValue(), internalSymbol, fromId, startTime, endTime, limit) .map { TradeResponse( symbol ?: "", 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 d4dc0b3d5..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,8 +20,7 @@ import org.springframework.web.bind.annotation.RestController import java.math.BigDecimal import java.security.Principal import java.time.ZoneId - -@RestController +@RestController("binanceMarketController") class MarketController( private val accountantProxy: AccountantProxy, private val marketDataProxy: MarketDataProxy, @@ -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) @@ -176,29 +183,30 @@ class MarketController( } // Custom service - @GetMapping("/v3/currencyInfo") - suspend fun getNetworks(@RequestParam(required = false) currency: String?): List { - return blockchainGatewayProxy.getCurrencyImplementations(currency) - .groupBy { it.currency } - .toList() - .map { pair -> - CurrencyNetworkResponse( - pair.first.symbol, - pair.first.name, - pair.second.map { - CurrencyNetwork( - it.chain.name, - it.implCurrency.symbol, - it.withdrawMin, - it.withdrawFee, - it.token, - it.tokenAddress - ) - } - ) - } - } - +// @GetMapping("/v3/currencyInfo") +// suspend fun getNetworks(@RequestParam(required = false) currency: String?): List { +// return blockchainGatewayProxy.getCurrencyImplementations(currency) +// .groupBy { it.currency } +// .toList() +// .map { pair -> +// CurrencyNetworkResponse( +// pair.first.symbol, +// pair.first.name, +// pair.second.map { +// CurrencyNetwork( +// it.chain.name, +// it.implCurrency.symbol, +// it.withdrawMin, +// it.withdrawFee, +// it.token, +// it.tokenAddress +// ) +// } +// ) +// } +// } + @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 0b21b8399..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 @@ -1,235 +1,26 @@ package co.nilin.opex.api.ports.binance.controller -import co.nilin.opex.api.core.inout.DepositDetails -import co.nilin.opex.api.core.inout.TransactionHistoryResponse -import co.nilin.opex.api.core.spi.* -import co.nilin.opex.api.ports.binance.data.* -import co.nilin.opex.api.ports.binance.util.jwtAuthentication -import co.nilin.opex.api.ports.binance.util.tokenValue -import co.nilin.opex.common.OpexError -import co.nilin.opex.common.utils.Interval +import co.nilin.opex.api.core.spi.MarketDataProxy +import co.nilin.opex.api.core.spi.WalletProxy +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 -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.math.BigDecimal -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.util.* -@RestController +@RestController("walletBinanceController") +@io.swagger.v3.oas.annotations.Hidden +@Deprecated("") class WalletController( private val walletProxy: WalletProxy, - private val symbolMapper: SymbolMapper, private val marketDataProxy: MarketDataProxy, - private val accountantProxy: AccountantProxy, - private val bcGatewayProxy: BlockchainGatewayProxy, ) { - - @GetMapping("/v1/capital/deposit/address") - suspend fun assignAddress( - @RequestParam - coin: String, - @RequestParam - network: String, - @RequestParam(required = false) - recvWindow: Long?, //The value cannot be greater than 60000 - @RequestParam - timestamp: Long, - @CurrentSecurityContext securityContext: SecurityContext - ): AssignAddressResponse { - val response = bcGatewayProxy.assignAddress(securityContext.jwtAuthentication().name, coin, network) - val address = response?.addresses - if (address.isNullOrEmpty()) throw OpexError.InternalServerError.exception() - return AssignAddressResponse(address[0].address, coin, network, "", "") - } - - @GetMapping("/v1/capital/deposit/hisrec") - suspend fun getDepositTransactions( - @RequestParam(required = false) - coin: String?, - @RequestParam("network", required = false) - status: Int?, - @RequestParam(required = false) - startTime: Long?, - @RequestParam(required = false) - endTime: Long?, - @RequestParam(required = false) - offset: Int?, - @RequestParam(required = false) - limit: Int?, - @RequestParam(required = false) - recvWindow: Long?, //The value cannot be greater than 60000 - @RequestParam - timestamp: Long, - @RequestParam(required = false) - ascendingByTime: Boolean? = false, - @CurrentSecurityContext securityContext: SecurityContext - ): List { - val validLimit = limit ?: 1000 - val deposits = walletProxy.getDepositTransactions( - securityContext.jwtAuthentication().name, - securityContext.jwtAuthentication().tokenValue(), - coin, - startTime ?: null, - endTime ?: null, - if (validLimit > 1000 || validLimit < 1) 1000 else validLimit, - offset ?: 0, - ascendingByTime - ) - if (deposits.isEmpty()) - return emptyList() - - val details = bcGatewayProxy.getDepositDetails(deposits.filterNot { it.ref.isNullOrBlank() }.map { it.ref!! }) - return matchDepositsAndDetails(deposits, details) - } - - @GetMapping("/v1/capital/withdraw/history") - suspend fun getWithdrawTransactions( - @RequestParam(required = false) - coin: String, - @RequestParam(required = false) - withdrawOrderId: String?, - @RequestParam("status", required = false) - withdrawStatus: Int?, - @RequestParam(required = false) - offset: Int?, - @RequestParam(required = false) - limit: Int?, - @RequestParam(required = false) - startTime: Long?, - @RequestParam(required = false) - endTime: Long?, - @RequestParam(required = false) - ascendingByTime: Boolean? = false, - @RequestParam(required = false) - recvWindow: Long?, //The value cannot be greater than 60000 - @RequestParam - timestamp: Long, - @CurrentSecurityContext securityContext: SecurityContext - ): List { - val validLimit = limit ?: 1000 - val response = walletProxy.getWithdrawTransactions( - securityContext.jwtAuthentication().name, - securityContext.jwtAuthentication().tokenValue(), - coin, - startTime ?: null, - endTime ?: null, - if (validLimit > 1000 || validLimit < 1) 1000 else validLimit, - offset ?: 0, - ascendingByTime - ) - return response.map { - val status = when (it.status) { - "CREATED" -> 0 - "DONE" -> 1 - "REJECTED" -> 2 - else -> -1 - } - - WithdrawResponse( - it.destAddress ?: "0x0", - it.amount, - LocalDateTime.ofInstant(Instant.ofEpochMilli(it.createDate), ZoneId.systemDefault()) - .toString() - .replace("T", " "), - it.destSymbol ?: "", - it.withdrawId?.toString() ?: "", - "", - it.destNetwork ?: "", - 1, - status, - it.appliedFee.toString(), - 3, - it.destTransactionRef ?: it.withdrawId.toString(), - if (status == 1 && it.acceptDate != null) it.acceptDate!! else it.createDate - ) - } - } - - - @PostMapping("/v2/capital/withdraw/history") - suspend fun getWithdrawTransactionsV2( - @RequestBody withdrawRequest: WithDrawRequest, - @CurrentSecurityContext securityContext: SecurityContext - ): List { - val validLimit = withdrawRequest.limit ?: 1000 - val response = walletProxy.getWithdrawTransactions( - securityContext.jwtAuthentication().name, - securityContext.jwtAuthentication().tokenValue(), - withdrawRequest.coin, - withdrawRequest.startTime ?: null, - withdrawRequest.endTime ?: null, - if (validLimit > 1000 || validLimit < 1) 1000 else validLimit, - withdrawRequest.offset ?: 0, - withdrawRequest.ascendingByTime - ) - return response.map { - val status = when (it.status) { - "CREATED" -> 0 - "DONE" -> 1 - "REJECTED" -> 2 - else -> -1 - } - - WithdrawResponse( - it.destAddress ?: "0x0", - it.amount, - LocalDateTime.ofInstant(Instant.ofEpochMilli(it.createDate), ZoneId.systemDefault()) - .toString() - .replace("T", " "), - it.destSymbol ?: "", - it.withdrawId?.toString() ?: "", - "", - it.destNetwork ?: "", - 1, - status, - it.appliedFee.toString(), - 3, - it.destTransactionRef ?: it.withdrawId.toString(), - if (status == 1 && it.acceptDate != null) it.acceptDate!! else it.createDate - ) - } - } - - @GetMapping("/v1/asset/tradeFee") - suspend fun getPairFees( - @RequestParam(required = false) - symbol: String?, - @RequestParam(required = false) - recvWindow: Long?, //The value cannot be greater than 60000 - @RequestParam - timestamp: Long - ): List { - return if (symbol != null) { - val internalSymbol = symbolMapper.toInternalSymbol(symbol) ?: throw OpexError.SymbolNotFound.exception() - - val fee = accountantProxy.getFeeConfig(internalSymbol) - arrayListOf().apply { - add( - PairFeeResponse( - symbol, - fee.makerFee.toDouble(), - fee.takerFee.toDouble() - ) - ) - } - } else - accountantProxy.getFeeConfigs() - .distinctBy { it.pair } - .map { - PairFeeResponse( - symbolMapper.fromInternalSymbol(it.pair) ?: it.pair, - it.makerFee.toDouble(), - it.takerFee.toDouble() - ) - } - } - @GetMapping("/v1/asset/getUserAsset") suspend fun getUserAssets( @CurrentSecurityContext @@ -302,31 +93,4 @@ class WalletController( wallets.find { it.asset.equals(quoteAsset, true) }?.let { value += it.balance } return AssetsEstimatedValue(value, quoteAsset.uppercase(), zeroAssets) } - - private fun matchDepositsAndDetails( - deposits: List, - details: List - ): List { - val detailMap = details.associateBy { it.hash } - return deposits.associateWith { - detailMap[it.ref] - }.mapNotNull { (deposit, detail) -> - detail?.let { - DepositResponse( - deposit.amount, - deposit.currency, - detail.chain, - 1, - detail.address, - null, - deposit.ref ?: deposit.id.toString(), - deposit.date, - 1, - "1/1", - "1/1", - deposit.date - ) - } - } - } } diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/ExchangeInfoResponse.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/ExchangeInfoResponse.kt index d2cbdc718..f27c92a21 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/ExchangeInfoResponse.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/ExchangeInfoResponse.kt @@ -1,6 +1,6 @@ package co.nilin.opex.api.ports.binance.data -import co.nilin.opex.api.core.inout.PairFeeResponse +import co.nilin.opex.api.core.inout.FeeConfig import java.util.* data class ExchangeInfoResponse( @@ -9,6 +9,6 @@ data class ExchangeInfoResponse( val rateLimits: List = RateLimit.values() .map { RateLimitResponse(it.rateLimitType, it.interval, it.intervalNum, it.limit) }, val exchangeFilters: List = emptyList(), - val fees: List = emptyList(), + val fees: List = emptyList(), val symbols: List ) \ No newline at end of file diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/NewOrderResponse.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/NewOrderResponse.kt index 69bbe87a2..cc50e652f 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/NewOrderResponse.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/NewOrderResponse.kt @@ -4,7 +4,6 @@ import co.nilin.opex.api.core.inout.OrderSide import co.nilin.opex.api.core.inout.OrderStatus import co.nilin.opex.api.core.inout.OrderType import co.nilin.opex.api.core.inout.TimeInForce -import co.nilin.opex.api.ports.binance.controller.AccountController import com.fasterxml.jackson.annotation.JsonInclude import java.math.BigDecimal import java.util.* diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/TradeResponse.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/TradeResponse.kt index 05c2d28aa..5ef611cfa 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/TradeResponse.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/TradeResponse.kt @@ -2,6 +2,7 @@ package co.nilin.opex.api.ports.binance.data import com.fasterxml.jackson.annotation.JsonInclude import java.math.BigDecimal +import java.time.LocalDateTime import java.util.* @JsonInclude(JsonInclude.Include.NON_NULL) @@ -15,7 +16,7 @@ data class TradeResponse( val quoteQty: BigDecimal, val commission: BigDecimal, val commissionAsset: String, - val time: Date, + val time: LocalDateTime, val isBuyer: Boolean, val isMaker: Boolean, val isBestMatch: Boolean diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/WithDrawRequest.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/WithDrawRequest.kt index 4b32b5080..00cccfaa3 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/WithDrawRequest.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/data/WithDrawRequest.kt @@ -1,18 +1,17 @@ package co.nilin.opex.api.ports.binance.data import com.fasterxml.jackson.annotation.JsonProperty -import org.springframework.web.bind.annotation.RequestParam data class WithDrawRequest( - var coin: String?, - var withdrawOrderId: String?, - @JsonProperty("status") - var withdrawStatus: Int?, - var offset: Int?, - var limit: Int?, - var startTime: Long?, - var endTime: Long?, - var ascendingByTime: Boolean? = false, - var recvWindow: Long?, //The value cannot be greater than 60000 - var timestamp: Long, + var coin: String?, + var withdrawOrderId: String?, + @JsonProperty("status") + var withdrawStatus: Int?, + var offset: Int?, + var limit: Int?, + var startTime: Long?, + var endTime: Long?, + var ascendingByTime: Boolean? = false, + var recvWindow: Long?, //The value cannot be greater than 60000 + var timestamp: Long, ) diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/AudienceValidator.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/AudienceValidator.kt new file mode 100644 index 000000000..eb6aa7a94 --- /dev/null +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.api.ports.binance.util + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/pom.xml b/api/api-ports/api-opex-rest/pom.xml index 50a08fdce..6b533adf5 100644 --- a/api/api-ports/api-opex-rest/pom.xml +++ b/api/api-ports/api-opex-rest/pom.xml @@ -44,28 +44,6 @@ org.springframework.boot spring-boot-starter-actuator - - org.springframework.boot - spring-boot-starter-data-r2dbc - - - org.postgresql - r2dbc-postgresql - runtime - - - org.postgresql - postgresql - runtime - - - io.projectreactor.kotlin - reactor-kotlin-extensions - - - org.jetbrains.kotlinx - kotlinx-coroutines-reactor - org.springframework.boot spring-boot-starter-security @@ -89,9 +67,19 @@ test - io.swagger - swagger-annotations - 1.5.20 + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.8.14 + + + org.springframework.boot + spring-boot-starter-validation + + + central + https://repo1.maven.org/maven2/ + + diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/ApiJacksonConfig.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/ApiJacksonConfig.kt new file mode 100644 index 000000000..6221b77f3 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/ApiJacksonConfig.kt @@ -0,0 +1,38 @@ +package co.nilin.opex.api.ports.opex.config + +import co.nilin.opex.api.ports.opex.util.DateToEpochSerializer +import co.nilin.opex.api.ports.opex.util.LocalDateTimeToEpochSerializer +import co.nilin.opex.api.ports.opex.util.LocalDateToEpochSerializer +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.* + +@Configuration +class ApiJacksonConfig { + + @Bean + @Primary + fun apiObjectMapper(): ObjectMapper { + + val module = SimpleModule().apply { + addSerializer(LocalDateTime::class.java, LocalDateTimeToEpochSerializer()) + addSerializer(LocalDate::class.java, LocalDateToEpochSerializer()) + addSerializer(Date::class.java, DateToEpochSerializer()) + + } + + return JsonMapper.builder() + .addModule(JavaTimeModule()) + .addModule(KotlinModule.Builder().build()) + .addModule(module) + .build() + } +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/ErrorHandlerConfig.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/ErrorHandlerConfig.kt index 9a1e1afd5..d512dbc55 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/ErrorHandlerConfig.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/ErrorHandlerConfig.kt @@ -3,7 +3,7 @@ package co.nilin.opex.api.ports.opex.config import co.nilin.opex.utility.error.EnableOpexErrorHandler import org.springframework.context.annotation.Configuration -@Configuration +@Configuration("opexErrorHandlerConfig") @EnableOpexErrorHandler class ErrorHandlerConfig { diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/RestConfig.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/RestConfig.kt index 1416783c2..5fe320f8b 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/RestConfig.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/RestConfig.kt @@ -7,7 +7,7 @@ import org.springframework.format.Formatter import org.springframework.web.server.WebFilter import java.util.* -@Configuration +@Configuration("opexRestConfig") class RestConfig { @Bean fun dateFormatter(): Formatter? { diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/RestTemplateConfig.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/RestTemplateConfig.kt new file mode 100644 index 000000000..f950047ac --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/RestTemplateConfig.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.api.ports.opex.config + +import org.springframework.cloud.client.loadbalancer.LoadBalanced +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.web.client.DefaultResponseErrorHandler +import org.springframework.web.client.RestTemplate + +@Configuration +class RestTemplateConfig { + + @Bean + @LoadBalanced + fun restTemplate(): RestTemplate { + val factory = SimpleClientHttpRequestFactory().apply { + setConnectTimeout(100000) + setReadTimeout(10000) + } + + val restTemplate = RestTemplate(factory) + restTemplate.errorHandler = DefaultResponseErrorHandler() + + return restTemplate + } + + +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/SecurityConfig.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/SecurityConfig.kt deleted file mode 100644 index f42735a79..000000000 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/SecurityConfig.kt +++ /dev/null @@ -1,57 +0,0 @@ -package co.nilin.opex.api.ports.opex.config - -import co.nilin.opex.api.core.spi.APIKeyFilter -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Bean -import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity -import org.springframework.security.config.web.server.SecurityWebFiltersOrder -import org.springframework.security.config.web.server.ServerHttpSecurity -import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder -import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder -import org.springframework.security.web.server.SecurityWebFilterChain -import org.springframework.web.reactive.function.client.WebClient -import org.springframework.web.server.WebFilter - -@EnableWebFluxSecurity -class SecurityConfig(private val webClient: WebClient) { - - @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String - - @Autowired - private lateinit var apiKeyFilter: APIKeyFilter - - @Bean - fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { - http.csrf().disable() - .authorizeExchange() - .pathMatchers("/actuator/**").permitAll() - .pathMatchers("/swagger-ui/**").permitAll() - .pathMatchers("/swagger-resources/**").permitAll() - .pathMatchers("/v2/api-docs").permitAll() - .pathMatchers("/v3/depth").permitAll() - .pathMatchers("/v3/trades").permitAll() - .pathMatchers("/v3/ticker/**").permitAll() - .pathMatchers("/v3/exchangeInfo").permitAll() - .pathMatchers("/v3/currencyInfo/**").permitAll() - .pathMatchers("/v3/klines").permitAll() - .pathMatchers("/socket").permitAll() - .pathMatchers("/v1/landing/**").permitAll() - .pathMatchers("/**").hasAuthority("SCOPE_trust") - .anyExchange().authenticated() - .and() - .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) - .oauth2ResourceServer() - .jwt() - return http.build() - } - - @Bean - @Throws(Exception::class) - fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) - .webClient(webClient) - .build() - } -} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/WebClientConfig.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/WebClientConfig.kt deleted file mode 100644 index 7da7c0aad..000000000 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/config/WebClientConfig.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.nilin.opex.api.ports.opex.config - -import org.springframework.cloud.client.ServiceInstance -import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer -import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.WebClient -import org.zalando.logbook.Logbook -import org.zalando.logbook.netty.LogbookClientHandler -import reactor.netty.http.client.HttpClient - -@Configuration -class WebClientConfig { - - @Bean - fun webClient(loadBalancerFactory: ReactiveLoadBalancer.Factory, logbook: Logbook): WebClient { - val client = HttpClient.create().doOnConnected { it.addHandlerLast(LogbookClientHandler(logbook)) } - return WebClient.builder() - //.clientConnector(ReactorClientHttpConnector(client)) - .filter(ReactorLoadBalancerExchangeFilterFunction(loadBalancerFactory, emptyList())) - .build() - } - -} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AccountController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AccountController.kt deleted file mode 100644 index 2fef5ab05..000000000 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AccountController.kt +++ /dev/null @@ -1,14 +0,0 @@ -package co.nilin.opex.api.ports.opex.controller - -import co.nilin.opex.api.core.inout.* -import co.nilin.opex.api.core.spi.MarketUserDataProxy -import co.nilin.opex.api.core.spi.MatchingGatewayProxy -import co.nilin.opex.api.core.spi.SymbolMapper -import co.nilin.opex.api.core.spi.WalletProxy -import org.springframework.web.bind.annotation.* - - -@RestController -class AccountController( - -) {} \ 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/AddressBookController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressBookController.kt new file mode 100644 index 000000000..2455d3ca0 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressBookController.kt @@ -0,0 +1,204 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.AddAddressBookItemRequest +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.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 +) { + + @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 + @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) + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AdminSwapController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AdminSwapController.kt new file mode 100644 index 000000000..5fefbdba2 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AdminSwapController.kt @@ -0,0 +1,111 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.AdminTransferReserveRequest +import co.nilin.opex.api.core.inout.ReservedTransferResponse +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/admin/swap") +@Tag(name = "Admin Swap", description = "Admin impersonated swap reserve and finalize operations.") +class AdminSwapController( + val walletProxy: WalletProxy +) { + @PostMapping("/reserve") + @Operation( + summary = "Admin Reserve Swap", + description = """POST /opex/v1/admin/swap/reserve. +Security: Bearer admin-token required. Requires authenticated admin JWT. +Behavior: Admin can reserve swap on behalf of a user by specifying custom rate or amounts.""", + 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.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. User does not have admin permissions.", + content = [Content()] + ) + ] + ) + suspend fun reserve( + @RequestBody request: AdminTransferReserveRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): ReservedTransferResponse { + return walletProxy.reserveSwapByAdmin(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/finalize/{reserveUuid}") + @Operation( + summary = "Admin Finalize Transfer", + description = """POST /opex/v1/admin/swap/finalize/{reserveUuid}. +Security: Bearer admin-token required. Requires authenticated admin JWT. +Behavior: Finalizes a reserved swap on behalf of the user.""", + 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.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. User does not have admin permissions.", + 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.finalizeSwapByAdmin( + securityContext.jwtAuthentication().tokenValue(), + reserveUuid, + description, + 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/AuthController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt new file mode 100644 index 000000000..425ba12d9 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt @@ -0,0 +1,165 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import co.nilin.opex.api.core.inout.auth.* +import co.nilin.opex.api.core.spi.AuthProxy +import co.nilin.opex.api.core.spi.ManualRateLimiterService +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.http.ResponseEntity +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ServerWebExchange + +@RestController +@RequestMapping("/opex/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 authProxy: AuthProxy, private val manualRateLimiterService: ManualRateLimiterService) { + @PostMapping("/token") + @Operation( + summary = "Request token", + description = """POST /opex/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 = authProxy.requestGetToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } + + @PostMapping("/token/confirm") + @Operation( + summary = "Confirm token request", + description = """POST /opex/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, + @Parameter(hidden = true) exchange: ServerWebExchange + ): ResponseEntity { + manualRateLimiterService.check(tokenRequest.username, ManualRateLimitGroupType.VERIFY_OTP, exchange) + val tokenResponse = authProxy.confirmGetToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } + + @PostMapping("/token/resend-otp") + @Operation( + summary = "Resend login OTP", + description = """POST /opex/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 = authProxy.resendLoginOtp(resendOtpRequest, securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok().body(response) + } + + @PostMapping("/token-external") + @Operation( + summary = "Request token by external IdP", + description = """POST /opex/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 = authProxy.getToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } + + @PostMapping("/refresh") + @Operation( + summary = "Refresh token", + description = """POST /opex/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 = authProxy.refreshToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } +} \ 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 new file mode 100644 index 000000000..0d9b8140d --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt @@ -0,0 +1,91 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.AddBankAccountRequest +import co.nilin.opex.api.core.inout.BankAccountResponse +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 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 +) { + + @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 + @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) + } +} 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/CurrencyRatesController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt new file mode 100644 index 000000000..8a5aaeb3d --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt @@ -0,0 +1,792 @@ +package co.nilin.opex.api.ports.opex.controller + +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.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") +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 + ) { + request.validate() + rateProxy.createRate(securityContext.jwtAuthentication().tokenValue(), request) + } + + @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 { + request.validate() + return rateProxy.updateRate(securityContext.jwtAuthentication().tokenValue(), request) + } + + @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 + ): Rates { + return rateProxy.deleteRate(securityContext.jwtAuthentication().tokenValue(), sourceSymbol, destSymbol) + } + + @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 + ): Rate? { + return rateProxy.fetchRate(sourceSymbol, destSymbol) + } + + // 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 + ) { + request.validate() + rateProxy.addForbiddenPair(securityContext.jwtAuthentication().tokenValue(), request) + } + + @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 + ): ForbiddenPairs { + return rateProxy.deleteForbiddenPair(securityContext.jwtAuthentication().tokenValue(), sourceSymbol, destSymbol) + } + + @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 + ) { + rateProxy.addTransitiveSymbols(securityContext.jwtAuthentication().tokenValue(), symbols) + } + + @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 { + return rateProxy.deleteTransitiveSymbol(securityContext.jwtAuthentication().tokenValue(), symbol) + } + + @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 { + return rateProxy.deleteTransitiveSymbols(securityContext.jwtAuthentication().tokenValue(), symbols) + } + + @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 + @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", 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 { + return rateProxy.getPrice(unit) + } +} 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 new file mode 100644 index 000000000..92628de81 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositAdminController.kt @@ -0,0 +1,58 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.ManualTransferRequest +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 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 +) { + @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( + securityContext.jwtAuthentication().tokenValue(), + symbol, + receiverUuid, + amount, + request + ) + } + +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositWebhookController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositWebhookController.kt new file mode 100644 index 000000000..12180c572 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/DepositWebhookController.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.DepositWebhookRequest +import co.nilin.opex.api.core.inout.DepositWebhookResponse +import co.nilin.opex.api.core.inout.DepositWebhookHeaders +import co.nilin.opex.api.core.spi.WalletProxy +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/v1/deposit") +class DepositWebhookController( + private val walletProxy: WalletProxy +) { + @PostMapping( + "/webhook" + ) + suspend fun submit( + @RequestHeader(DepositWebhookHeaders.SIGNATURE) sign: String, @RequestBody body: DepositWebhookRequest + ): DepositWebhookResponse { + return walletProxy.submitDepositWebhook( + body, + sign + ) + } +} + + 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 new file mode 100644 index 000000000..ddf72c5b9 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/GatewayAdminController.kt @@ -0,0 +1,550 @@ +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.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: CurrencyGatewayUpdateCommand, + ): CurrencyGatewayCommand? { + return walletProxy.updateGateway( + securityContext.jwtAuthentication().tokenValue(), + gatewayUuid, + currencySymbol, + body + ) + } + + @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, + ): CurrencyGatewayCommand? { + return walletProxy.getGateway( + securityContext.jwtAuthentication().tokenValue(), + gatewayUuid, + currencySymbol + ) + } + + @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 new file mode 100644 index 000000000..e84ced5fc --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/LocalizationAdminController.kt @@ -0,0 +1,268 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.spi.BlockchainGatewayProxy +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 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 { + return walletProxy.getCurrencyLocalizations(securityContext.jwtAuthentication().tokenValue(), currency) + } + + @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 + ): CurrencyLocalizationResponse { + return walletProxy.saveCurrencyLocalizations( + securityContext.jwtAuthentication().tokenValue(), + currency, + currencyLocalizations + ) + } + + @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 + ) { + walletProxy.deleteCurrencyLocalization(securityContext.jwtAuthentication().tokenValue(), id) + } + + @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 { + return walletProxy.getTerminalLocalizations(securityContext.jwtAuthentication().tokenValue(), terminalUuid) + } + + @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 + ): TerminalLocalizationResponse { + return walletProxy.saveTerminalLocalizations( + securityContext.jwtAuthentication().tokenValue(), + terminalUuid, + terminalLocalizations + ) + } + + @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 + ) { + walletProxy.deleteTerminalLocalization(securityContext.jwtAuthentication().tokenValue(), id) + } + + @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 { + return if (gatewayUuid.startsWith("ofg")) { + walletProxy.getOffChainGatewayLocalizations( + securityContext.jwtAuthentication().tokenValue(), + gatewayUuid + ) + } else if (gatewayUuid.startsWith("ong")) { + blockchainGatewayProxy.getOnChainGatewayLocalizations( + securityContext.jwtAuthentication().tokenValue(), gatewayUuid + ) + } else throw OpexError.GatewayNotFount.exception() + } + + @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 + ): GatewayLocalizationResponse { + return if (gatewayUuid.startsWith("ofg")) { + walletProxy.saveOffChainGatewayLocalizations( + securityContext.jwtAuthentication().tokenValue(), + gatewayUuid, + gatewayLocalizations + ) + } else if (gatewayUuid.startsWith("ong")) { + blockchainGatewayProxy.saveOnChainGatewayLocalizations( + securityContext.jwtAuthentication().tokenValue(), + gatewayUuid, + gatewayLocalizations + ) + } else throw OpexError.GatewayNotFount.exception() + } + + @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 + ) { + return if (gatewayUuid.startsWith("ofg")) { + walletProxy.deleteOffChainGatewayLocalization( + securityContext.jwtAuthentication().tokenValue(), + id + ) + } else if (gatewayUuid.startsWith("ong")) { + blockchainGatewayProxy.deleteOnChainGatewayLocalization( + securityContext.jwtAuthentication().tokenValue(), + id + ) + } else throw OpexError.GatewayNotFount.exception() + } +} 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 new file mode 100644 index 000000000..290c55827 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt @@ -0,0 +1,877 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.spi.* +import co.nilin.opex.api.ports.opex.data.MarketInfoResponse +import co.nilin.opex.api.ports.opex.data.MarketStatResponse +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.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 + +@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, + private val marketDataProxy: MarketDataProxy, + private val walletProxy: WalletProxy, + private val matchingGatewayProxy: MatchingGatewayProxy, + private val blockChainGatewayProxy: BlockchainGatewayProxy, + @Value("\${app.user-activity-reference-currency}") + 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 } + + return accountantProxy.getPairConfigs().mapNotNull { config -> + pairSettings[config.pair]?.run { + PairInfoResponse( + pair = config.pair, + baseAsset = config.leftSideWalletSymbol, + quoteAsset = config.rightSideWalletSymbol, + isAvailable = isAvailable, + minOrder = minOrder, + maxOrder = maxOrder, + orderTypes = orderTypes + ) + } + } + } + + @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() + } + + @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( + @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( + @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) + + val mostIncreased = async { + marketStatProxy.getMostIncreasedInPricePairs(intervalEnum, validLimit) + } + + val mostDecreased = async { + marketStatProxy.getMostDecreasedInPricePairs(intervalEnum, validLimit) + } + + val highestVolume = async { + marketStatProxy.getHighestVolumePair(intervalEnum) + } + + val mostTrades = async { + marketStatProxy.getTradeCountPair(intervalEnum) + } + + MarketStatResponse( + mostIncreased.await(), + mostDecreased.await(), + highestVolume.await(), + mostTrades.await() + ) + } + + @GetMapping("/info") + @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) + ) + } + + @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( + @Parameter( + name = "symbol", + description = "Market symbol.", + required = true, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol") + symbol: String, + + @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)) + OpexError.InvalidLimitForOrderBook.exception() + + val mappedBidOrders = ArrayList>() + val mappedAskOrders = ArrayList>() + + val bidOrders = marketDataProxy.openBidOrders(symbol, validLimit) + val askOrders = marketDataProxy.openAskOrders(symbol, validLimit) + + bidOrders.forEach { + val mapped = arrayListOf().apply { + add(it.price ?: BigDecimal.ZERO) + add(it.quantity ?: BigDecimal.ZERO) + } + mappedBidOrders.add(mapped) + } + + askOrders.forEach { + val mapped = arrayListOf().apply { + add(it.price ?: BigDecimal.ZERO) + add(it.quantity ?: BigDecimal.ZERO) + } + mappedAskOrders.add(mapped) + } + + val lastOrder = marketDataProxy.lastOrder(symbol) + return OrderBookResponse(lastOrder?.orderId ?: -1, mappedBidOrders, mappedAskOrders) + } + + @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( + @Parameter( + name = "symbol", + description = "Market symbol.", + required = true, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol") + symbol: String, + + @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) + OpexError.InvalidLimitForRecentTrades.exception() + + return marketDataProxy.recentTrades(symbol, validLimit) + .map { + RecentTradeResponse( + it.id, + it.price, + it.quantity, + it.quoteQuantity, + it.time.time, + it.isMakerBuyer, + it.isBestMatch + ) + } + } + + @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( + @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() + + val interval = Interval.findByLabel(duration) ?: Interval.Week + + val result = if (symbol.isNullOrEmpty()) + marketDataProxy.getTradeTickerData(interval).toMutableList() + else + arrayListOf(marketDataProxy.getTradeTickerDataBySymbol(symbol, interval)) + + result.forEach { + val parts = it.symbol?.split("_") + if (parts != null && parts.size == 2) { + it.base = parts[0].uppercase() + it.quote = parts[1].uppercase() + } + } + + return if (quote.isNullOrEmpty()) result else result.filter { it.quote.equals(quote, true) } + } + + @GetMapping("/ticker/price") + @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( + @Parameter( + name = "symbol", + description = "Market symbol.", + required = true, + example = "BTCUSDT" + ) + @RequestParam(name = "symbol") + symbol: String, + + @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, + + @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?, + + @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?, + + @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) + throw OpexError.InvalidLimitForRecentTrades.exception() + + val i = Interval.findByLabel(interval) ?: throw OpexError.InvalidInterval.exception() + + val list = ArrayList>() + marketDataProxy.getCandleInfo(symbol, "${i.duration} ${i.unit}", startTime, endTime, validLimit) + .forEach { + list.add( + arrayListOf( + it.openTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), + it.open.toString(), + it.high.toString(), + it.low.toString(), + it.close.toString(), + it.volume.toString(), + it.closeTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), + it.quoteAssetVolume.toString(), + it.trades, + it.takerBuyBaseAssetVolume.toString(), + it.takerBuyQuoteAssetVolume.toString(), + "0.0" + ) + ) + } + return list + } + + @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 }, + 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( + @Parameter( + name = "gatewayUuid", + description = "Gateway UUID.", + required = true, + example = "ofg-uuid-sample" + ) + @PathVariable("gatewayUuid") + gatewayUuid: String + ): List? { + return walletProxy.getGatewayTerminal(gatewayUuid) + } + + private fun getValidLimit(limit: Int?): Int = when { + limit == null -> 100 + limit > 1000 -> 1000 + limit < 1 -> 1 + else -> limit + } +} 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 new file mode 100644 index 000000000..c5a28eeb3 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/OrderController.kt @@ -0,0 +1,505 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.spi.MarketUserDataProxy +import co.nilin.opex.api.core.spi.MatchingGatewayProxy +import co.nilin.opex.api.ports.opex.data.CancelOrderResponse +import co.nilin.opex.api.ports.opex.data.NewOrderResponse +import co.nilin.opex.api.ports.opex.data.QueryOrderResponse +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.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 java.math.BigDecimal +import java.security.Principal +import java.time.ZoneId +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( + @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, + price ?: BigDecimal.ZERO, + quantity ?: BigDecimal.ZERO, + side.asOrderDirection(), + timeInForce?.asMatchConstraint(), + type.asMatchingOrderType(), + "*", + securityContext.jwtAuthentication().tokenValue() + ) + return NewOrderResponse(symbol) + } + + @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, + + @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") + val order = queryHandler.queryOrder(securityContext.jwtAuthentication().tokenValue(), symbol, orderId, origClientOrderId) + ?: throw OpexError.OrderNotFound.exception() + val response = CancelOrderResponse( + symbol, + origClientOrderId, + orderId, + -1, + null, + order.price, + order.quantity, + order.executedQuantity, + order.accumulativeQuoteQty, + OrderStatus.CANCELED, + order.constraint.asTimeInForce(), + order.type.asOrderType(), + order.direction.asOrderSide() + ) + 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, + order.orderId ?: 0, + symbol, + securityContext.jwtAuthentication().tokenValue() + ) + return response + } + + @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) + @CurrentSecurityContext securityContext: SecurityContext, + + @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(securityContext.jwtAuthentication().tokenValue(), symbol, orderId, origClientOrderId) + ?.asQueryOrderResponse() + ?.apply { this.symbol = symbol } + ?: throw OpexError.OrderNotFound.exception() + } + + @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) + @CurrentSecurityContext securityContext: SecurityContext, + + @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(securityContext.jwtAuthentication().tokenValue(), symbol, limit).map { + it.asQueryOrderResponse().apply { symbol?.let { s -> this.symbol = s } } + } + } + + private fun validateNewOrderParams( + type: OrderType, + price: BigDecimal?, + quantity: BigDecimal?, + timeInForce: TimeInForce?, + stopPrice: BigDecimal?, + quoteOrderQty: BigDecimal?, + ) { + when (type) { + OrderType.LIMIT -> { + checkDecimal(price, "price") + checkDecimal(quantity, "quantity") + checkNull(timeInForce, "timeInForce") + } + + OrderType.MARKET -> { + if (quantity == null) checkDecimal(quoteOrderQty, "quoteOrderQty") + else checkDecimal(quantity, "quantity") + } + + OrderType.STOP_LOSS -> { + checkDecimal(quantity, "quantity") + checkDecimal(stopPrice, "stopPrice") + } + + OrderType.STOP_LOSS_LIMIT -> { + checkDecimal(price, "price") + checkDecimal(quantity, "quantity") + checkDecimal(stopPrice, "stopPrice") + checkNull(timeInForce, "timeInForce") + } + + OrderType.TAKE_PROFIT -> { + checkDecimal(quantity, "quantity") + checkDecimal(stopPrice, "stopPrice") + } + + OrderType.TAKE_PROFIT_LIMIT -> { + checkDecimal(price, "price") + checkDecimal(quantity, "quantity") + checkDecimal(stopPrice, "stopPrice") + checkNull(timeInForce, "timeInForce") + } + + OrderType.LIMIT_MAKER -> { + checkDecimal(price, "price") + checkDecimal(quantity, "quantity") + } + } + } + + private fun checkDecimal(decimal: BigDecimal?, paramName: String) { + if (decimal == null || decimal <= BigDecimal.ZERO) + throw OpexError.InvalidRequestParam.exception("Parameter '$paramName' is either missing or invalid") + } + + private fun checkNull(obj: Any?, paramName: String) { + if (obj == null) + throw OpexError.InvalidRequestParam.exception("Parameter '$paramName' is either missing or invalid") + } + + private fun Order.asQueryOrderResponse() = QueryOrderResponse( + symbol, + ouid, + orderId ?: 0, + -1, + "", + price, + quantity, + executedQuantity, + accumulativeQuoteQty, + status, + constraint.asTimeInForce(), + type.asOrderType(), + direction.asOrderSide(), + null, + null, + Date.from(createDate.atZone(ZoneId.systemDefault()).toInstant()), + Date.from(updateDate.atZone(ZoneId.systemDefault()).toInstant()), + status.isWorking(), + quoteQuantity + ) +} 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 new file mode 100644 index 000000000..956bf60ba --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileAdminController.kt @@ -0,0 +1,289 @@ +package co.nilin.opex.api.ports.opex.controller + +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, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): List { + return profileProxy.getProfiles( + securityContext.jwtAuthentication().tokenValue(), + profileRequest + ) + } + + @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, + @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, + @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(), + uuid, + limit ?: 10, + offset ?: 0 + ) + } + + @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, + @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, + @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( + securityContext.jwtAuthentication().tokenValue(), + changeRequestStatusBody + ) + } +} 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 new file mode 100644 index 000000000..1939a55d9 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/ProfileController.kt @@ -0,0 +1,190 @@ +package co.nilin.opex.api.ports.opex.controller + +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.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.* + +@RestController +@RequestMapping("/opex/v1/profile") + +@Tag( + name = "Profile", + description = "Authenticated user profile operations." +) +class ProfileController( + val profileProxy: ProfileProxy +) { + + @GetMapping("/personal-data") + @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) + ?.toProfileResponse() + } + + @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) + } + + @PostMapping("/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) + + } + + @GetMapping("/approval-request") + @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() + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/PublicAuthController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/PublicAuthController.kt new file mode 100644 index 000000000..868d2208c --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/PublicAuthController.kt @@ -0,0 +1,197 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import co.nilin.opex.api.core.inout.auth.* +import co.nilin.opex.api.core.spi.AuthProxy +import co.nilin.opex.api.core.spi.ManualRateLimiterService +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 jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ServerWebExchange + +@RestController +@RequestMapping("/opex/v1/user/public") +@Tag( + name = "Auth Gateway - Public User", + description = "Public registration and password-recovery operations." +) +class PublicAuthController( + private val authProxy: AuthProxy, + private val manualRateLimiterService: ManualRateLimiterService +) { + + + @PostMapping("/register") + @Operation( + summary = "Register user", + description = """POST /opex/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 = authProxy.registerUser(request) + return ResponseEntity.ok().body(otpResponse) + } + + @PostMapping("/register/verify") + @Operation( + summary = "Verify registration OTP", + description = """POST /opex/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, @Parameter(hidden = true) exchange: ServerWebExchange + ): ResponseEntity { + manualRateLimiterService.check(request.username, ManualRateLimitGroupType.VERIFY_OTP, exchange) + val response = authProxy.verifyRegister(request) + return ResponseEntity.ok(response) + } + + @PostMapping("/register/confirm") + @Operation( + summary = "Confirm registration", + description = """POST /opex/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 = authProxy.confirmRegister(request) + return ResponseEntity.ok(loginToken) + } + + @PostMapping("/register-external") + @Operation( + summary = "Register external IdP user", + description = """POST /opex/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 { + authProxy.registerExternalIdpUser(request) + return ResponseEntity.ok().build() + } + + @PostMapping("/forget") + @Operation( + summary = "Forgot password", + description = """POST /opex/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 = authProxy.forgetPassword(request) + return ResponseEntity.ok().body(otpResponse) + } + + @PostMapping("/forget/verify") + @Operation( + summary = "Verify forgot-password OTP", + description = """POST /opex/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, + @Parameter(hidden = true) exchange: ServerWebExchange + ): ResponseEntity { + manualRateLimiterService.check(request.username, ManualRateLimitGroupType.VERIFY_OTP, exchange) + val response = authProxy.verifyForget(request) + return ResponseEntity.ok(response) + } + + @PostMapping("/forget/confirm") + @Operation( + summary = "Confirm forgot-password flow", + description = """POST /opex/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 { + authProxy.confirmForget(request) + return ResponseEntity.ok().build() + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SessionController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SessionController.kt new file mode 100644 index 000000000..cd9496bbd --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SessionController.kt @@ -0,0 +1,138 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.auth.SessionRequest +import co.nilin.opex.api.core.inout.auth.Sessions +import co.nilin.opex.api.core.spi.AuthProxy +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.* + +@RestController +@RequestMapping("/opex/v1/user") +@Tag( + name = "Auth Gateway - Sessions", description = "Authenticated user session and logout operations." +) +class SessionController(private val authProxy: AuthProxy) { + + @PostMapping("/logout") + @Operation( + summary = "Logout current session", description = """POST /opex/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 + ) { + authProxy.logout(securityContext.jwtAuthentication().tokenValue()) + } + + @PostMapping("/session") + @Operation( + summary = "List user sessions", + description = """POST /opex/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 { + return authProxy.getSessions(sessionRequest, securityContext.jwtAuthentication().tokenValue()) + } + + @DeleteMapping("/session/{sessionId}") + @Operation( + summary = "Logout one session", description = """DELETE /opex/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 + ) { + authProxy.logout(sessionId, securityContext.jwtAuthentication().tokenValue()) + } + + @PostMapping("/session/delete-others") + @Operation( + summary = "Logout other sessions", description = """POST /opex/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 + ) { + authProxy.logoutOthers(securityContext.jwtAuthentication().tokenValue()) + } + + @PostMapping("/session/delete-all") + @Operation( + summary = "Logout all sessions", description = """POST /opex/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 + ) { + authProxy.logoutAll(securityContext.jwtAuthentication().tokenValue()) + } +} 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 new file mode 100644 index 000000000..bd0484223 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageAdminController.kt @@ -0,0 +1,207 @@ +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, + @Parameter(name = "key", description = "Storage object key.", required = true) + @RequestParam("key") key: String + ): ResponseEntity { + return storageProxy.adminDownload(securityContext.jwtAuthentication().tokenValue(), bucket, key) + } + + @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, + + @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 + ) + 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, + @Parameter(name = "key", description = "Storage object key.", required = true) + @RequestParam("key") key: String + ) { + storageProxy.adminDelete(securityContext.jwtAuthentication().tokenValue(), bucket, key) + } +} 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 new file mode 100644 index 000000000..04d4e8c54 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/StorageController.kt @@ -0,0 +1,49 @@ +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 +import org.springframework.web.bind.annotation.RequestParam +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 +) { + @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, + @Parameter(name = "key", description = "Storage object key.", required = true) + @RequestParam("key") key: String + ): ResponseEntity { + return storageProxy.publicDownload(bucket, key) + } + +} 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 new file mode 100644 index 000000000..52b2779fc --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SwapController.kt @@ -0,0 +1,108 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.ReservedTransferResponse +import co.nilin.opex.api.core.inout.TransferReserveRequest +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 +) { + @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( + securityContext.jwtAuthentication().tokenValue(), + reserveUuid, + description, + transferRef + ) + } +} 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 new file mode 100644 index 000000000..09cc709e7 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TerminalAdminController.kt @@ -0,0 +1,313 @@ +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 +) { + @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) + } + + + @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: 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, + @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( + @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, + @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, + @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 + ) { + return walletProxy.assignTerminalsToGateway( + securityContext.jwtAuthentication().tokenValue(), + gatewayUuid, + terminal + ) + } + + @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 + ) { + return walletProxy.revokeTerminalsToGateway( + securityContext.jwtAuthentication().tokenValue(), + gatewayUuid, + terminal + ) + } + +} 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 new file mode 100644 index 000000000..d7dc44c66 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/TransactionAdminController.kt @@ -0,0 +1,299 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.spi.MarketDataProxy +import co.nilin.opex.api.core.spi.WalletProxy +import co.nilin.opex.api.ports.opex.service.OwnerNameResolver +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import co.nilin.opex.common.utils.LoggerDelegate +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 +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/admin/transactions") +@Tag(name = "Transactions Admin", description = "Admin transaction history and summary operations.") +class TransactionAdminController( + private val walletProxy: WalletProxy, + private val marketDataProxy: MarketDataProxy, + private val ownerNameResolver: OwnerNameResolver, +) { + private val logger by LoggerDelegate() + + @PostMapping("/summary") + suspend fun getUserTransactionHistory( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @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 { + return walletProxy.getDepositTransactionsForAdmin( + securityContext.jwtAuthentication().tokenValue(), + request + ) + } + + @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 { + return walletProxy.getWithdrawTransactionsForAdmin( + securityContext.jwtAuthentication().tokenValue(), + request + ) + } + + @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: UserSwapTransactionRequest + ): List { + return walletProxy.getSwapTransactionsForAdmin( + securityContext.jwtAuthentication().tokenValue(), + request + ) + } + + @Deprecated("Use /v2/trades instead") + // 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 + ): List { + return walletProxy.getTradeHistoryForAdmin(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/v2/trades") + @Operation( + summary = "Get trades transactions", + description = """POST /opex/v1/admin/transactions/v2/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 = TradeAdminItem::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 searchMarketTrades( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: AdminTradesHistoryRequest, + ): List { + val token = securityContext.jwtAuthentication().tokenValue() + val items = marketDataProxy.recentTrades(token, request) + if (request.includeNames == false) return items + val uuids = items.flatMap { listOfNotNull(it.makerUuid, it.takerUuid) }.toSet() + val nameMap = ownerNameResolver.resolve(token, uuids) + return items.map { + it.copy( + makerOwnerName = nameMap[it.makerUuid], + takerOwnerName = nameMap[it.takerUuid] + ) + } + } + + @PostMapping("/v2/orders") + @Operation( + summary = "Get orders history", + description = """POST /opex/v1/admin/transactions/v2/orders. +Security: Bearer admin-token required. Required authority: ROLE_monitoring or ROLE_admin. +Allowed values: +- OrderType: LIMIT_ORDER, MARKET_ORDER +- OrderDirection: ASK, BID +""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = OrderAdminItem::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 searchMarketOrders( + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: AdminOrdersHistoryRequest, + ): List { + val token = securityContext.jwtAuthentication().tokenValue() + val items = marketDataProxy.recentOrders( + token, request + ) + if (request.includeNames == false) return items + val uuids = items.flatMap { listOfNotNull(it.uuid) }.toSet() + val nameMap = ownerNameResolver.resolve(token, uuids) + return items.map { + it.copy( + ownerName = nameMap[it.uuid], + ) + } + } +} 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 new file mode 100644 index 000000000..5a0fe2e95 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt @@ -0,0 +1,115 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.UserDetailAssetsSnapshot +import co.nilin.opex.api.core.inout.analytics.ActivityTotals +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 +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +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 +) { + + @GetMapping("/user-activity") + @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?, + @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 new file mode 100644 index 000000000..155570cfc --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserDataController.kt @@ -0,0 +1,171 @@ +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.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 +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 + +@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 +) { + + @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, + @Parameter( + name = "interval", + description = "Interval. For user data endpoints allowed values are Day, Week, Month, Year. ", + required = true + ) + @RequestParam interval: LimitedInterval + ): BigDecimal { + 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, + @Parameter( + name = "interval", + description = "Interval. For user data endpoints allowed values are Day, Week, Month, Year.", + required = true + ) + @RequestParam interval: LimitedInterval + ): BigDecimal { + val interval = Interval.valueOf(interval.name) + val uuid = securityContext.authentication.name + return accountantProxy.getTotalTradeVolumeValue(uuid, interval) + } + + @GetMapping("/fee") + @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, + @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.valueOf(interval.name) } + ) + +} 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 new file mode 100644 index 000000000..07fcc2d42 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserHistoryController.kt @@ -0,0 +1,918 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.spi.MarketUserDataProxy +import co.nilin.opex.api.core.spi.WalletProxy +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( + @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( + securityContext.jwtAuthentication().tokenValue(), + symbol, + startTime, + endTime, + orderType, + direction, + limit ?: 10, + offset ?: 0, + ).map { it.toResponse() } + } + + @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( + @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( + securityContext.jwtAuthentication().tokenValue(), + symbol, + startTime, + endTime, + orderType, + direction, + ) + } + + @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( + @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.jwtAuthentication().tokenValue(), + 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( + @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.jwtAuthentication().tokenValue(), + 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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + currency, + status, + startTime, + endTime, + limit ?: 10, + offset ?: 0, + ascendingByTime, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + currency, + status, + startTime, + endTime, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + currency, + startTime, + endTime, + limit ?: 10, + offset ?: 0, + ascendingByTime, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + currency, + startTime, + endTime, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + currency, + category, + startTime, + endTime, + limit ?: 10, + offset ?: 0, + ascendingByTime, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + currency, + category, + startTime, + endTime, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + startTime, + endTime, + limit, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + startTime, + endTime, + limit, + ) + } + + @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( + @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( + securityContext.jwtAuthentication().name, + securityContext.jwtAuthentication().tokenValue(), + startTime, + endTime, + limit, + ) + } + + @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: 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: UserSwapTransactionRequest + ): Long { + return walletProxy.getSwapTransactionsCount(securityContext.jwtAuthentication().tokenValue(), request) + } +} 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 new file mode 100644 index 000000000..f0e6ec518 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/VoucherController.kt @@ -0,0 +1,48 @@ +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 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()) + } +} 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 new file mode 100644 index 000000000..cb5412c45 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletAdminController.kt @@ -0,0 +1,106 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.WalletDataResponse +import co.nilin.opex.api.core.inout.WalletTotal +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 org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +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( + securityContext.jwtAuthentication().tokenValue(), + uuid, + currency, + excludeSystem, + limit ?: 10, + offset ?: 0 + ) + } + + @GetMapping("/system/total") + @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") + @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 new file mode 100644 index 000000000..f5ccedebd --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WalletController.kt @@ -0,0 +1,125 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.AssignAddressRequest +import co.nilin.opex.api.core.inout.OwnerLimitsResponse +import co.nilin.opex.api.core.spi.BlockchainGatewayProxy +import co.nilin.opex.api.core.spi.WalletProxy +import co.nilin.opex.api.ports.opex.data.AssetResponse +import co.nilin.opex.api.ports.opex.data.AssignAddressResponse +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +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.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 +) { + + @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, + @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() + + if (symbol != null) { + val wallet = walletProxy.getWallet(auth.name, auth.tokenValue(), symbol.uppercase()) + result.add(AssetResponse(wallet.asset, wallet.balance, wallet.locked, wallet.withdraw)) + } else { + result.addAll( + walletProxy.getWallets(auth.name, auth.tokenValue()) + .map { AssetResponse(it.asset, it.balance, it.locked, it.withdraw) } + ) + } + return result + } + + @GetMapping("/limits") + @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() + ) + } + + @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 { + + val response = bcGatewayProxy.assignAddress( + AssignAddressRequest( + securityContext.authentication.name, + currency, + gatewayUuid + ) + ) + val address = response?.addresses + if (address.isNullOrEmpty()) throw OpexError.InternalServerError.exception() + return AssignAddressResponse(address[0].address, currency, address[0].expTime, address[0].assignedDate) + } +} 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 new file mode 100644 index 000000000..8a902b4e8 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawAdminController.kt @@ -0,0 +1,143 @@ +package co.nilin.opex.api.ports.opex.controller + +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 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 +) { + @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( + securityContext.jwtAuthentication().tokenValue(), + symbol, + sourceUuid, + amount, + request + ) + } + + @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, + @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 + ): 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 + ): WithdrawActionResult { + return walletProxy.rejectWithdraw(securityContext.jwtAuthentication().tokenValue(), withdrawUuid, request) + } +} 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 new file mode 100644 index 000000000..42a94b3c4 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/WithdrawController.kt @@ -0,0 +1,244 @@ +package co.nilin.opex.api.ports.opex.controller + +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 +) { + + @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? { + return walletProxy.requestWithdraw( + securityContext.jwtAuthentication().tokenValue(), + request + ) + } + + @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( + securityContext.jwtAuthentication().tokenValue(), + withdrawUuid + ) + } + + @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( + securityContext.jwtAuthentication().tokenValue(), + withdrawUuid + ) + } + + @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, + @Parameter(name = "otpCode", description = "OTP code received by user.", required = true) + @RequestParam otpCode: String + ): WithdrawActionResult { + return walletProxy.verifyWithdrawOTP( + securityContext.jwtAuthentication().tokenValue(), + withdrawUuid, + otpType, + otpCode + ) + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/AssetResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/AssetResponse.kt new file mode 100644 index 000000000..e852bc4fd --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/AssetResponse.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.ports.opex.data + +import java.math.BigDecimal + +data class AssetResponse( + val asset: String, + var free: BigDecimal, + var locked: BigDecimal, + var withdrawing: BigDecimal, +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/AssignAddressResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/AssignAddressResponse.kt new file mode 100644 index 000000000..c8a741bd6 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/AssignAddressResponse.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.ports.opex.data + +import java.time.LocalDateTime + +data class AssignAddressResponse( + val address: String, + val currency: String, + var expTime: LocalDateTime? = null, + var assignedDate: LocalDateTime? = null, +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/CancelOrderResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/CancelOrderResponse.kt new file mode 100644 index 000000000..29e46426c --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/CancelOrderResponse.kt @@ -0,0 +1,25 @@ +package co.nilin.opex.api.ports.opex.data + +import co.nilin.opex.api.core.inout.OrderSide +import co.nilin.opex.api.core.inout.OrderStatus +import co.nilin.opex.api.core.inout.OrderType +import co.nilin.opex.api.core.inout.TimeInForce +import com.fasterxml.jackson.annotation.JsonInclude +import java.math.BigDecimal + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CancelOrderResponse( + val symbol: String, + val origClientOrderId: String?, + val orderId: Long?, + val orderListId: Long, //Unless OCO, value will be -1 + val clientOrderId: String?, + val price: BigDecimal?, + val origQty: BigDecimal?, + val executedQty: BigDecimal?, + val cummulativeQuoteQty: BigDecimal?, + val status: OrderStatus?, + val timeInForce: TimeInForce?, + val type: OrderType?, + val side: OrderSide? +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/MarketInfoResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/MarketInfoResponse.kt new file mode 100644 index 000000000..01b1a0c11 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/MarketInfoResponse.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.ports.opex.data + +data class MarketInfoResponse( + val activeUsers: Long, + val totalOrders: Long, + val totalTrades: Long +) diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/MarketStatResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/MarketStatResponse.kt new file mode 100644 index 000000000..bcdef449d --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/MarketStatResponse.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.ports.opex.data + +import co.nilin.opex.api.core.inout.PriceStat +import co.nilin.opex.api.core.inout.TradeVolumeStat + +data class MarketStatResponse( + val mostIncreasedPrice: List, + val mostDecreasedPrice: List, + val mostVolume: TradeVolumeStat?, + val mostTrades: TradeVolumeStat? +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/NewOrderResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/NewOrderResponse.kt new file mode 100644 index 000000000..15aa2d3c5 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/NewOrderResponse.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.ports.opex.data + +import com.fasterxml.jackson.annotation.JsonInclude +import java.util.* + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class NewOrderResponse( + val symbol: String, + val date: Date = Date() +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/OrderBookResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/OrderBookResponse.kt new file mode 100644 index 000000000..6ba08c2fc --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/OrderBookResponse.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.ports.opex.data + +import java.math.BigDecimal + +data class OrderBookResponse( + val lastUpdateId: Long, + val bids: List>, // Inner list -> [0]: PRICE, [1]: QTY + val asks: List> // Inner list -> [0]: PRICE, [1]: QTY +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/OrderDataResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/OrderDataResponse.kt new file mode 100644 index 000000000..269b95352 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/OrderDataResponse.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.api.ports.opex.data + +import co.nilin.opex.api.core.inout.MatchingOrderType +import co.nilin.opex.api.core.inout.OrderDirection +import co.nilin.opex.api.core.inout.OrderStatus +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* + +data class OrderDataResponse( + val symbol: String, + val orderId: Long, + val orderType: MatchingOrderType, + val side: OrderDirection, + val price: BigDecimal, + val quantity: BigDecimal, + val quoteQuantity: BigDecimal, + val executedQuantity: BigDecimal, + val takerFee: BigDecimal, + val makerFee: BigDecimal, + val status: OrderStatus, + val createDate: LocalDateTime, + val updateDate: LocalDateTime, +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/QueryOrderResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/QueryOrderResponse.kt new file mode 100644 index 000000000..044fd8347 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/QueryOrderResponse.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.api.ports.opex.data + +import co.nilin.opex.api.core.inout.OrderSide +import co.nilin.opex.api.core.inout.OrderStatus +import co.nilin.opex.api.core.inout.OrderType +import co.nilin.opex.api.core.inout.TimeInForce +import com.fasterxml.jackson.annotation.JsonInclude +import java.math.BigDecimal +import java.util.* + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class QueryOrderResponse( + var symbol: String, + val ouid: String, + val orderId: Long, + val orderListId: Long, //Unless part of an OCO, the value will always be -1. + val clientOrderId: String, + val price: BigDecimal, + val origQty: BigDecimal, + val executedQty: BigDecimal, + val cummulativeQuoteQty: BigDecimal, + val status: OrderStatus, + val timeInForce: TimeInForce, + val type: OrderType, + val side: OrderSide, + val stopPrice: BigDecimal?, + val icebergQty: BigDecimal?, + val time: Date, + val updateTime: Date, + val isWorking: Boolean, + val origQuoteOrderQty: BigDecimal +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/RecentTradeResponse.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/RecentTradeResponse.kt new file mode 100644 index 000000000..f49ab4669 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/data/RecentTradeResponse.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.ports.opex.data + +import java.math.BigDecimal + +data class RecentTradeResponse( + val id: Long, + val price: BigDecimal, + val qty: BigDecimal, + val quoteQty: BigDecimal, + val time: Long, + val isBuyerMaker: Boolean, + val isBestMatch: Boolean +) \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/service/OwnerNameResolver.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/service/OwnerNameResolver.kt new file mode 100644 index 000000000..7b0894c5a --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/service/OwnerNameResolver.kt @@ -0,0 +1,56 @@ +package co.nilin.opex.api.ports.opex.service + +import co.nilin.opex.api.core.inout.ResolveUsersRequest +import co.nilin.opex.api.core.spi.ProfileProxy +import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap + +@Component +class OwnerNameResolver( + private val profileProxy: ProfileProxy, +) { + private data class CacheEntry(val name: String?, val expiresAt: Long) + + private val logger by LoggerDelegate() + + private val cache = ConcurrentHashMap() + private val ttl: Duration = Duration.ofDays(1) + + suspend fun resolve(token: String, uuids: Set): Map { + if (uuids.isEmpty()) return emptyMap() + + val now = Instant.now().toEpochMilli() + val cached = mutableMapOf() + val missing = mutableListOf() + + uuids.forEach { uuid -> + val entry = cache[uuid] + if (entry != null && entry.expiresAt > now) { + cached[uuid] = entry.name + } else { + missing.add(uuid) + } + } + + if (missing.isNotEmpty()) { + try { + val result = profileProxy.resolveUsers(token, ResolveUsersRequest(missing)) + val expiry = Instant.now().plus(ttl).toEpochMilli() + result.filter { (uuid, name) -> name != null }.forEach { (uuid, name) -> + cache[uuid] = CacheEntry(name, expiry) + } + val notReturned = missing.filterNot { result.containsKey(it) } + notReturned.forEach { uuid -> cache[uuid] = CacheEntry(null, expiry) } + + cached.putAll(result) + } catch (t: Throwable) { + logger.debug("Error in fetching users data $t") + } + } + + return cached + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/service/UserActivityAggregationService.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/service/UserActivityAggregationService.kt new file mode 100644 index 000000000..02705f24e --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/service/UserActivityAggregationService.kt @@ -0,0 +1,51 @@ +package co.nilin.opex.api.ports.opex.service + +import co.nilin.opex.api.core.inout.analytics.ActivityTotals +import co.nilin.opex.api.core.spi.AccountantProxy +import co.nilin.opex.api.core.spi.WalletProxy +import co.nilin.opex.api.ports.opex.util.toTimestamp +import org.springframework.stereotype.Service +import java.math.BigDecimal + +@Service +class UserActivityAggregationService( + private val walletProxy: WalletProxy, + private val accountantProxy: AccountantProxy +) { + + suspend fun getLast31DaysUserStats( + token: String, + userId: String + ): Map { + + val balances = walletProxy.getDailyBalanceLast31Days(token, userId) + val withdraws = accountantProxy.getDailyWithdrawLast31Days(userId) + val deposits = accountantProxy.getDailyDepositLast31Days(userId) + val trades = accountantProxy.getDailyTradeLast31Days(userId) + + // Collect all dates + val allDates = ( + balances.map { it.date } + + withdraws.map { it.date } + + deposits.map { it.date } + + trades.map { it.date } + ).toSet() + + // Index by date for fast lookup + val balanceMap = balances.associateBy { it.date } + val withdrawMap = withdraws.associateBy { it.date } + val depositMap = deposits.associateBy { it.date } + val tradeMap = trades.associateBy { it.date } + + // Build final map + return allDates.associateWith { date -> + ActivityTotals( + totalBalance = balanceMap[date]?.totalAmount ?: BigDecimal.ZERO, + totalWithdraw = withdrawMap[date]?.totalAmount ?: BigDecimal.ZERO, + totalDeposit = depositMap[date]?.totalAmount ?: BigDecimal.ZERO, + totalTrade = tradeMap[date]?.totalAmount ?: BigDecimal.ZERO, + totalOrder = BigDecimal.ZERO + ) + }.mapKeys { (key, value) -> key.toTimestamp() }.toSortedMap() + } +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/ConvertorExtenstions.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/ConvertorExtenstions.kt new file mode 100644 index 000000000..d815a9f99 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/ConvertorExtenstions.kt @@ -0,0 +1,101 @@ +package co.nilin.opex.api.ports.opex.util + +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.ports.opex.data.OrderDataResponse +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +fun OrderData.toResponse(): OrderDataResponse { + return OrderDataResponse( + symbol = this.symbol, + orderId = this.orderId, + orderType = this.orderType, + side = this.side, + price = this.price, + quantity = this.quantity, + quoteQuantity = this.quoteQuantity, + executedQuantity = this.executedQuantity, + takerFee = this.takerFee, + makerFee = this.makerFee, + status = this.status, + createDate = this.createDate, + updateDate = this.updateDate, + ) +} + +fun Profile.toProfileResponse(): ProfileResponse { + return ProfileResponse( + email = this.email, + userId = this.userId, + firstName = this.firstName, + lastName = this.lastName, + address = this.address, + mobile = this.mobile, + telephone = this.telephone, + postalCode = this.postalCode, + nationality = this.nationality, + identifier = this.identifier, + gender = this.gender, + birthDate = this.birthDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), + status = this.status, + createDate = this.createDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), + lastUpdateDate = this.lastUpdateDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), + creator = this.creator, + kycLevel = this.kycLevel, + mobileIdentityMatch = this.mobileIdentityMatch, + personalIdentityMatch = this.personalIdentityMatch + ) +} + +fun ProfileApprovalRequestUser.toProfileApprovalRequestUserResponse(): ProfileApprovalRequestUserResponse { + return ProfileApprovalRequestUserResponse( + status = this.status, + createDate = this.createDate.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), + description = this.description, + ) +} + + +/** + * LocalDate -> epoch millis (start of day) + */ +fun LocalDate.toTimestamp( + zoneId: ZoneId = ZoneId.of("UTC") +): Long = + this + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli() + +/** + * LocalDateTime -> epoch millis + */ +fun LocalDateTime.toTimestamp( + zoneId: ZoneId = ZoneId.of("UTC") +): Long = + this + .atZone(zoneId) + .toInstant() + .toEpochMilli() + +/** + * epoch millis -> LocalDate + */ +fun Long.toLocalDate( + zoneId: ZoneId = ZoneId.of("UTC") +): LocalDate = + Instant.ofEpochMilli(this) + .atZone(zoneId) + .toLocalDate() + +/** + * epoch millis -> LocalDateTime + */ +fun Long.toLocalDateTime( + zoneId: ZoneId = ZoneId.of("UTC") +): LocalDateTime = + Instant.ofEpochMilli(this) + .atZone(zoneId) + .toLocalDateTime() diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/DateToEpochSerializer.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/DateToEpochSerializer.kt new file mode 100644 index 000000000..e68240bdb --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/DateToEpochSerializer.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.api.ports.opex.util + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import java.util.* + +class DateToEpochSerializer : + JsonSerializer() { + + override fun serialize( + value: Date?, + gen: JsonGenerator, + serializers: SerializerProvider + ) { + if (value == null) { + gen.writeNull() + return + } + gen.writeNumber(value.time) + } +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/EnumExtensions.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/EnumExtensions.kt new file mode 100644 index 000000000..4363a192e --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/EnumExtensions.kt @@ -0,0 +1,54 @@ +package co.nilin.opex.api.ports.opex.util + +import co.nilin.opex.api.core.inout.* + +fun OrderSide.asOrderDirection(): OrderDirection { + if (this == OrderSide.BUY) + return OrderDirection.BID + return OrderDirection.ASK +} + +fun OrderDirection.asOrderSide(): OrderSide { + if (this == OrderDirection.BID) + return OrderSide.BUY + return OrderSide.SELL +} + +fun TimeInForce.asMatchConstraint(): MatchConstraint { + return when (this) { + TimeInForce.GTC -> MatchConstraint.GTC + TimeInForce.IOC -> MatchConstraint.IOC + TimeInForce.FOK -> MatchConstraint.FOK + } +} + +fun MatchConstraint.asTimeInForce(): TimeInForce { + return when (this) { + MatchConstraint.GTC -> TimeInForce.GTC + MatchConstraint.IOC -> TimeInForce.IOC + MatchConstraint.FOK -> TimeInForce.FOK + else -> TimeInForce.GTC + } +} + +fun OrderType.asMatchingOrderType(): MatchingOrderType { + return when (this) { + OrderType.LIMIT -> MatchingOrderType.LIMIT_ORDER + OrderType.MARKET -> MatchingOrderType.MARKET_ORDER + else -> MatchingOrderType.LIMIT_ORDER + } +} + +fun MatchingOrderType.asOrderType(): OrderType { + return when (this) { + MatchingOrderType.LIMIT_ORDER -> OrderType.LIMIT + MatchingOrderType.MARKET_ORDER -> OrderType.MARKET + } +} + +fun > R.equalsAny(vararg equals: R): Boolean { + for (e in equals) + if (this == e) + return true + return false +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/LocalDateTimeToEpochSerializer.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/LocalDateTimeToEpochSerializer.kt new file mode 100644 index 000000000..6820338f8 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/LocalDateTimeToEpochSerializer.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.api.ports.opex.util + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import java.time.LocalDateTime +import java.time.ZoneOffset + +class LocalDateTimeToEpochSerializer : + JsonSerializer() { + + override fun serialize( + value: LocalDateTime, + gen: JsonGenerator, + serializers: SerializerProvider + ) { + val epochMillis = value + .atOffset(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + + gen.writeNumber(epochMillis) + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/LocalDateToEpochSerializer.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/LocalDateToEpochSerializer.kt new file mode 100644 index 000000000..f912065ad --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/LocalDateToEpochSerializer.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.api.ports.opex.util + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import java.time.LocalDate +import java.time.ZoneOffset + +class LocalDateToEpochSerializer : JsonSerializer() { + override fun serialize( + value: LocalDate?, + gen: JsonGenerator, + serializers: SerializerProvider + ) { + if (value == null) { + gen.writeNull() + return + } + + val epochMillis = value + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + + gen.writeNumber(epochMillis) + } +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/SecurityExtension.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/SecurityExtention.kt similarity index 100% rename from api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/SecurityExtension.kt rename to api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/util/SecurityExtention.kt diff --git a/api/api-ports/api-persister-postgres/pom.xml b/api/api-ports/api-persister-postgres/pom.xml index 695c5d793..02746c921 100644 --- a/api/api-ports/api-persister-postgres/pom.xml +++ b/api/api-ports/api-persister-postgres/pom.xml @@ -67,5 +67,17 @@ io.mockk mockk + + org.springframework.boot + spring-boot-starter-data-redis + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt index 47e7038ae..8b3551fba 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt @@ -1,24 +1,55 @@ package co.nilin.opex.api.ports.postgres.config +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.core.io.Resource +import org.springframework.context.annotation.Profile import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories -import org.springframework.r2dbc.core.DatabaseClient @Configuration @EnableR2dbcRepositories(basePackages = ["co.nilin.opex"]) -class PostgresConfig( - db: DatabaseClient, - @Value("classpath:schema.sql") private val schemaResource: Resource -) { +@Profile("!test") +class PostgresConfig { + private val logger = LoggerFactory.getLogger(PostgresConfig::class.java) + init { - val schemaReader = schemaResource.inputStream.reader() - val schema = schemaReader.readText().trim() - schemaReader.close() - val initDb = db.sql { schema } - initDb // initialize the database - .then() - .subscribe() // execute + logger.info("🔍 PostgresConfig loaded") + } + + @Bean + fun flywayConfig( + @Value("\${spring.datasource.url}") url: String, + @Value("\${spring.datasource.username}") user: String, + @Value("\${spring.datasource.password}") password: String + ): Flyway? { + val flyway: Flyway = Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .baselineVersion("1") + .load() + try { + retry(6, 5000) { + flyway.migrate() + } + } catch (e: Exception) { + logger.error("❌ Flyway migration failed", e) + } + return flyway + } + + fun retry(times: Int, delayMs: Long, block: () -> Unit) { + var attempt = 0 + while (true) { + try { + block() + return + } catch (e: Exception) { + if (++attempt >= times) throw e + Thread.sleep(delayMs) + } + } } -} +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt deleted file mode 100644 index 21fa5d9fc..000000000 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package co.nilin.opex.api.ports.postgres.dao - -import co.nilin.opex.api.ports.postgres.model.APIKeyModel -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono - -@Repository -interface APIKeyRepository : ReactiveCrudRepository { - - fun findAllByUserId(userId: String): Flux - - fun findByKey(key: String): Mono - - fun countByUserId(userId: String): Mono - -} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt new file mode 100644 index 000000000..4868f26a5 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.ApiKeyRegistryModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ApiKeyRegistryRepository : ReactiveCrudRepository { +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitEndpointRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitEndpointRepository.kt new file mode 100644 index 000000000..aca1a7fc2 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitEndpointRepository.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.RateLimitEndpointModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface RateLimitEndpointRepository : ReactiveCrudRepository { + fun findByEnabledTrue(): Flux + +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitGroupRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitGroupRepository.kt new file mode 100644 index 000000000..3b7dfcc76 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitGroupRepository.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.RateLimitGroupModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface RateLimitGroupRepository : ReactiveCrudRepository { + fun findByEnabledTrue(): Flux + +} diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitPenaltyRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitPenaltyRepository.kt new file mode 100644 index 000000000..a84d8c95a --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitPenaltyRepository.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.RateLimitPenaltyModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface RateLimitPenaltyRepository : ReactiveCrudRepository { + fun findByGroupIdOrderByBlockStepAsc(groupId: Long): Flux +} diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/SymbolMapRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/SymbolMapRepository.kt index 1ecc358fc..019d45939 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/SymbolMapRepository.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/SymbolMapRepository.kt @@ -2,6 +2,7 @@ package co.nilin.opex.api.ports.postgres.dao import co.nilin.opex.api.ports.postgres.model.SymbolMapModel import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.query.Param import org.springframework.data.repository.reactive.ReactiveCrudRepository import org.springframework.stereotype.Repository @@ -12,10 +13,10 @@ import reactor.core.publisher.Mono interface SymbolMapRepository : ReactiveCrudRepository { @Query("select * from symbol_maps where symbol = :symbol and alias_key = :aliasKey") - fun findByAliasKeyAndSymbol(aliasKey: String, @Param("symbol") symbol: String): Mono + fun findByAliasKeyAndSymbol(aliasKey: String, @Param("symbol") symbol: String): Mono? @Query("select * from symbol_maps where alias_key = :aliasKey and alias = :alias") - fun findByAliasKeyAndAlias(aliasKey: String, @Param("alias") alias: String): Mono + fun findByAliasKeyAndAlias(aliasKey: String, @Param("alias") alias: String): Mono? fun findAllByAliasKey(aliasKey: String): Flux } diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt new file mode 100644 index 000000000..8dfd80c1b --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt @@ -0,0 +1,126 @@ +package co.nilin.opex.api.ports.postgres.impl + +import co.nilin.opex.api.core.spi.APIKeyService +import co.nilin.opex.api.core.spi.ApiKeySecretCrypto +import co.nilin.opex.api.core.utils.toCsv +import co.nilin.opex.api.core.utils.toSet +import co.nilin.opex.api.ports.postgres.dao.ApiKeyRegistryRepository +import co.nilin.opex.api.ports.postgres.model.ApiKeyRegistryModel +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class APIKeyServiceImpl( + private val apiKeySecretCrypto: ApiKeySecretCrypto, + private val apiKeyRegistryRepository: ApiKeyRegistryRepository +) : APIKeyService { + + private fun toRecord(e: ApiKeyRegistryModel): APIKeyService.ApiKeyRecord = + APIKeyService.ApiKeyRecord( + apiKeyId = e.apiKeyId, + label = e.label, + enabled = e.enabled, + allowedIps = e.allowedIps.toSet(), + allowedEndpoints = e.allowedEndpoints.toSet(), + keycloakUserId = e.keycloakUserId, + keycloakUsername = e.keycloakUsername + ) + + override suspend fun createApiKeyRecord( + apiKeyId: String, + label: String?, + plaintextSecret: String, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUserId: String?, + keycloakUsername: String?, + enabled: Boolean + ): APIKeyService.ApiKeyCreateResult { + require(apiKeyId.isNotBlank()) { "apiKeyId is blank" } + val exists = apiKeyRegistryRepository.existsById(apiKeyId).awaitSingle() + if (exists) error("API key already exists: $apiKeyId") + val enc = apiKeySecretCrypto.encrypt(plaintextSecret) + val now = LocalDateTime.now() + val entry = ApiKeyRegistryModel( + apiKeyId = apiKeyId, + label = label, + encryptedSecret = enc, + enabled = enabled, + allowedIps = allowedIps.toCsv(), + allowedEndpoints = allowedEndpoints.toCsv(), + keycloakUserId = keycloakUserId, + keycloakUsername = keycloakUsername, + createdAt = now, + updatedAt = now + ) + val saved = apiKeyRegistryRepository.save(entry).awaitSingle() + return APIKeyService.ApiKeyCreateResult( + secret = plaintextSecret, + record = toRecord(saved) + ) + } + + override suspend fun rotateApiKeySecret(apiKeyId: String, newPlaintextSecret: String): APIKeyService.ApiKeyCreateResult { + val existing = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: error("API key not found: $apiKeyId") + val enc = apiKeySecretCrypto.encrypt(newPlaintextSecret) + val updated = existing.copy( + encryptedSecret = enc, + updatedAt = LocalDateTime.now() + ) + val saved = apiKeyRegistryRepository.save(updated).awaitSingle() + return APIKeyService.ApiKeyCreateResult( + secret = newPlaintextSecret, + record = toRecord(saved) + ) + } + + override suspend fun updateApiKeyRecord( + apiKeyId: String, + label: String?, + enabled: Boolean?, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUsername: String? + ): APIKeyService.ApiKeyRecord { + val existing = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: error("API key not found: $apiKeyId") + val updated = existing.copy( + label = label ?: existing.label, + enabled = enabled ?: existing.enabled, + allowedIps = allowedIps.toCsv(), + allowedEndpoints = allowedEndpoints.toCsv(), + keycloakUsername = keycloakUsername ?: existing.keycloakUsername, + updatedAt = LocalDateTime.now() + ) + val saved = apiKeyRegistryRepository.save(updated).awaitSingle() + return toRecord(saved) + } + + override suspend fun getApiKeyRecord(apiKeyId: String): APIKeyService.ApiKeyRecord? { + val e = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: return null + return toRecord(e) + } + + override suspend fun listApiKeyRecords(): List = + apiKeyRegistryRepository.findAll().map { toRecord(it) }.collectList().awaitSingle().sortedBy { it.apiKeyId } + + override suspend fun deleteApiKeyRecord(apiKeyId: String) { + val exists = apiKeyRegistryRepository.existsById(apiKeyId).awaitSingle() + if (!exists) error("API key not found: $apiKeyId") + apiKeyRegistryRepository.deleteById(apiKeyId).awaitSingle() + } + + override suspend fun getApiKeyForVerification(apiKeyId: String): APIKeyService.ApiKeyVerification? { + val e = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: return null + val secret = try { apiKeySecretCrypto.decrypt(e.encryptedSecret) } catch (_: Exception) { return null } + return APIKeyService.ApiKeyVerification( + apiKeyId = apiKeyId, + secret = secret, + enabled = e.enabled, + allowedEndpoints = e.allowedEndpoints.toSet(), + allowedIps = e.allowedIps.toSet(), + keycloakUserId = e.keycloakUserId + ) + } +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt new file mode 100644 index 000000000..ee5175324 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt @@ -0,0 +1,78 @@ +package co.nilin.opex.api.ports.postgres.impl + +import co.nilin.opex.api.core.inout.RateLimitEndpoint +import co.nilin.opex.api.core.inout.RateLimitGroup +import co.nilin.opex.api.core.inout.RateLimitPenalty +import co.nilin.opex.api.core.spi.RateLimitConfigService +import co.nilin.opex.api.ports.postgres.dao.RateLimitEndpointRepository +import co.nilin.opex.api.ports.postgres.dao.RateLimitGroupRepository +import co.nilin.opex.api.ports.postgres.dao.RateLimitPenaltyRepository +import co.nilin.opex.api.ports.postgres.model.RateLimitEndpointModel +import co.nilin.opex.api.ports.postgres.model.RateLimitGroupModel +import co.nilin.opex.api.ports.postgres.model.RateLimitPenaltyModel +import kotlinx.coroutines.reactive.awaitFirstOrElse +import org.springframework.stereotype.Component + +@Component +class RateLimitConfigImpl( + private val groupRepo: RateLimitGroupRepository, + private val penaltyRepo: RateLimitPenaltyRepository, + private val endpointRepo: RateLimitEndpointRepository +) : RateLimitConfigService { + + private val groupCache = mutableMapOf() + private val groupNameCache = mutableMapOf() + private val penaltyCache = mutableMapOf>() + private val endpointCache = mutableListOf() + + override suspend fun loadConfig() { + val groups = groupRepo.findByEnabledTrue().collectList().awaitFirstOrElse { emptyList() } + groupCache.clear() + groupNameCache.clear() + + groups.forEach { + val group = it.toRateLimitGroup() + groupCache[it.id!!] = group + groupNameCache[group.name] = group + } + + penaltyCache.clear() + groups.forEach { group -> + val penalties = penaltyRepo.findByGroupIdOrderByBlockStepAsc(group.id!!).collectList() + .awaitFirstOrElse { emptyList() }.map { it.toRateLimitPenalty() } + penaltyCache[group.id] = penalties + } + + endpointCache.clear() + endpointCache.addAll(endpointRepo.findByEnabledTrue().collectList().awaitFirstOrElse { emptyList() } + .map { it.toRateLimitEndpoint() }) + } + + override fun getGroup(groupId: Long): RateLimitGroup? = groupCache[groupId] + override fun getGroup(groupName: String): RateLimitGroup? = groupNameCache[groupName] + override fun getPenalties(groupId: Long): List = penaltyCache[groupId] ?: emptyList() + override fun getEndpoints(): List = endpointCache + + + private fun RateLimitGroupModel.toRateLimitGroup(): RateLimitGroup = + RateLimitGroup(id, name, requestCount, requestWindowSeconds, cooldownSeconds, maxPenaltyLevel, enabled) + + + private fun RateLimitPenaltyModel.toRateLimitPenalty(): RateLimitPenalty = + RateLimitPenalty( + id, + groupId, + blockStep, + blockDurationSeconds + ) + + private fun RateLimitEndpointModel.toRateLimitEndpoint(): RateLimitEndpoint = + RateLimitEndpoint( + id, + url, + method, + groupId, + priority, + enabled + ) +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/SymbolMapperImpl.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/SymbolMapperImpl.kt index 0aa8abe91..f35eb1dea 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/SymbolMapperImpl.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/SymbolMapperImpl.kt @@ -13,12 +13,12 @@ class SymbolMapperImpl(val symbolMapRepository: SymbolMapRepository) : SymbolMap override suspend fun fromInternalSymbol(symbol: String?): String? { if (symbol == null) return null - return symbolMapRepository.findByAliasKeyAndSymbol("binance", symbol).awaitFirstOrNull()?.alias + return symbolMapRepository.findByAliasKeyAndSymbol("binance", symbol)?.awaitFirstOrNull()?.alias } override suspend fun toInternalSymbol(alias: String?): String? { if (alias == null) return null - return symbolMapRepository.findByAliasKeyAndAlias("binance", alias).awaitFirstOrNull()?.symbol + return symbolMapRepository.findByAliasKeyAndAlias("binance", alias)?.awaitFirstOrNull()?.symbol } override suspend fun symbolToAliasMap(): Map { diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt deleted file mode 100644 index 77f4bf4c5..000000000 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt +++ /dev/null @@ -1,23 +0,0 @@ -package co.nilin.opex.api.ports.postgres.model - -import org.springframework.data.annotation.Id -import org.springframework.data.relational.core.mapping.Column -import org.springframework.data.relational.core.mapping.Table -import java.time.LocalDateTime -import java.util.UUID - -@Table("api_key") -data class APIKeyModel( - @Id val id: Long? = null, - val userId: String, - val label: String, - var accessToken: String, - var refreshToken: String, - val expirationTime: LocalDateTime?, - @Column("allowed_ips") - val allowedIPs: String?, - var tokenExpirationTime: LocalDateTime, - val key: String = UUID.randomUUID().toString(), - var isEnabled: Boolean = true, - var isExpired: Boolean = false -) \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt new file mode 100644 index 000000000..e62255ad2 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("api_key_registry") +data class ApiKeyRegistryModel( + @Id + @Column("api_key_id") + val apiKeyId: String, + val label: String?, + @Column("encrypted_secret") + val encryptedSecret: String, + val enabled: Boolean, + @Column("allowed_ips") + val allowedIps: String?, + @Column("allowed_endpoints") + val allowedEndpoints: String?, + @Column("keycloak_user_id") + val keycloakUserId: String?, + @Column("keycloak_username") + val keycloakUsername: String?, + @Column("created_at") + val createdAt: LocalDateTime, + @Column("updated_at") + val updatedAt: LocalDateTime +): Persistable { + override fun getId(): String = apiKeyId + override fun isNew(): Boolean = createdAt == updatedAt +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitEndpointModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitEndpointModel.kt new file mode 100644 index 000000000..f8e0368b1 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitEndpointModel.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table(name = "rate_limit_endpoint") +data class RateLimitEndpointModel( + @Id + val id: Long? = null, + val url: String, + val method: String, + val groupId: Long, + val priority: Int, + val enabled: Boolean = true +) \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitGroupModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitGroupModel.kt new file mode 100644 index 000000000..506687988 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitGroupModel.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table(name = "rate_limit_group") +data class RateLimitGroupModel( + @Id + val id: Long? = null, + val name: String, + val requestCount: Int, + val requestWindowSeconds: Int, + val cooldownSeconds: Int, + val maxPenaltyLevel: Int, + val enabled: Boolean = true +) diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitPenaltyModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitPenaltyModel.kt new file mode 100644 index 000000000..82cd01fbc --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitPenaltyModel.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table(name = "rate_limit_penalty") +data class RateLimitPenaltyModel( + @Id + val id: Long? = null, + val groupId: Long, + val blockStep: Int, + val blockDurationSeconds: Int +) diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/util/RedisCacheHelper.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/util/RedisCacheHelper.kt new file mode 100644 index 000000000..cde37154e --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/util/RedisCacheHelper.kt @@ -0,0 +1,104 @@ +package co.nilin.opex.api.ports.postgres.util + +import co.nilin.opex.common.utils.DynamicInterval +import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component + +@Component +class RedisCacheHelper(private val redisTemplate: RedisTemplate) { + + private val logger by LoggerDelegate() + + private val valueOps = redisTemplate.opsForValue() + private val listOps = redisTemplate.opsForList() + + fun put(key: String, value: Any?, expireAt: DynamicInterval? = null) { + value ?: return + try { + valueOps.set(key, value) + expireAt?.let { redisTemplate.expireAt(key, it.dateInFuture()) } + } catch (e: Exception) { + logger.warn("Unable to put cache with key '$key'") + } + } + + fun putList(key: String, values: List, expireAt: DynamicInterval? = null) { + try { + values.forEach { listOps.rightPush(key, it) } + expireAt?.let { redisTemplate.expireAt(key, it.dateInFuture()) } + } catch (e: Exception) { + logger.warn("Unable to put list cache with key '$key'") + } + } + + fun putListItem(key: String, value: Any, rightPush: Boolean = true) { + try { + if (rightPush) + listOps.rightPush(key, value) + else + listOps.leftPush(key, value) + } catch (e: Exception) { + logger.warn("Unable to put list item cache with key '$key'") + } + } + + @Suppress("UNCHECKED_CAST") + fun get(key: String): T? { + return try { + valueOps.get(key) as T + } catch (e: Exception) { + logger.warn("Unable to get cache value with key '$key'") + null + } + } + + @Suppress("UNCHECKED_CAST") + fun getList(key: String): Collection? { + return try { + listOps.range(key, 0, -1) as Collection? + } catch (e: Exception) { + logger.warn("Unable to get cache list with key '$key'") + null + } + } + + suspend fun getOrElse(key: String, expireAt: DynamicInterval? = null, action: suspend () -> T): T { + val cacheValue = get(key) + return if (cacheValue != null) + cacheValue + else { + val value = action() + if (value != null) { + put(key, value) + expireAt?.let { setExpiration(key, it) } + } + return value + } + } + + fun evict(key: String) { + try { + redisTemplate.delete(key) + } catch (e: Exception) { + logger.warn("Unable to evict cache with key '$key'") + } + } + + fun setExpiration(key: String, interval: DynamicInterval) { + try { + redisTemplate.expireAt(key, interval.dateInFuture()) + } catch (e: Exception) { + logger.warn("Unable to set expiration date for cache with key '$key'") + } + } + + fun hasKey(key: String): Boolean { + return try { + redisTemplate.hasKey(key) + } catch (e: Exception) { + logger.warn("Unable fetch info of cache with key '$key'") + false + } + } +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 000000000..d762a3f5b --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1 @@ +CREATE TABLE IF NOT EXISTS test(id SERIAL PRIMARY KEY); \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql new file mode 100644 index 000000000..0c954b9fe --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql @@ -0,0 +1,54 @@ +CREATE TABLE IF NOT EXISTS symbol_maps +( + id SERIAL PRIMARY KEY, + symbol VARCHAR(72) NOT NULL, + alias_key VARCHAR(72) NOT NULL, + alias VARCHAR(72) NOT NULL, + UNIQUE (symbol, alias_key, alias) +); + +DROP TABLE IF EXISTS api_key; + +CREATE TABLE IF NOT EXISTS api_key_registry +( + api_key_id VARCHAR(128) PRIMARY KEY, + label VARCHAR(200), + encrypted_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + allowed_ips TEXT, + allowed_endpoints TEXT, + keycloak_user_id VARCHAR(128), + keycloak_username VARCHAR(256), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE TABLE IF NOT EXISTS rate_limit_group +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + request_count INT NOT NULL, + request_window_seconds INT NOT NULL, + cooldown_seconds INT NOT NULL, + max_penalty_level INT NOT NULL, + enabled BOOLEAN NOT NULL +); + +CREATE TABLE IF NOT EXISTS rate_limit_penalty +( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES rate_limit_group (id), + block_step INT NOT NULL, + block_duration_seconds INT NOT NULL, + unique (group_id, block_step) +); + +CREATE TABLE IF NOT EXISTS rate_limit_endpoint +( + id BIGSERIAL PRIMARY KEY, + url VARCHAR(255) NOT NULL, + method VARCHAR(10) NOT NULL, + group_id BIGINT NOT NULL REFERENCES rate_limit_group (id), + priority INT NOT NULL, + enabled BOOLEAN NOT NULL, + unique (url, method) +); diff --git a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V3__insert_rate_limit_data.sql b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V3__insert_rate_limit_data.sql new file mode 100644 index 000000000..c255b5e81 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V3__insert_rate_limit_data.sql @@ -0,0 +1,92 @@ +-- ________________ Admin Services ________________ +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (1, 'ADMIN', 15, 30, 120, 1, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (1, 1, 60); + +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'GET', 1, true, 1000); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'POST', 1, true, 1000); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'PUT', 1, true, 1000); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'DELETE', 1, true, 1000); +-- __________________________________________________________________________________________________________________________ + +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (2, 'HIGH_IMPACT', 5, 60, 600, 3, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (2, 3, 300); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (2, 2, 180); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (2, 1, 120); + + +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (3, 'LOW_IMPACT', 10, 60, 300, 3, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (3, 3, 180); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (3, 2, 120); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (3, 1, 60); + +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (4, 'BOT', 100, 60, 120, 1, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (4, 1, 60); + + +-- WithdrawController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/withdraw/**', 'POST', 2, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/withdraw/**', 'PUT', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/withdraw/**', 'GET', 3, true, 1); + +-- WalletController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/wallet/**', 'GET', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/wallet/deposit/address', 'GET', 2, true, 2); + +-- VoucherController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/voucher/**', 'PUT', 2, true, 1); + +-- UserHistoryController && UserDataController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/user/**', 'GET', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/user/**', 'POST', 3, true, 1); + +-- OrderController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/order/**', 'GET', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/order/**', 'PUT', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/order/**', 'POST', 3, true, 1); + +-- DepositController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/deposit/**', 'POST', 3, true, 1); + +-- RateController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/otc/**', 'POST', 1, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/otc/**', 'PUT', 4, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/otc/**', 'DELETE', 1, true, 1); \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql b/api/api-ports/api-persister-postgres/src/main/resources/schema.sql deleted file mode 100644 index 1d4db9878..000000000 --- a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS symbol_maps -( - id SERIAL PRIMARY KEY, - symbol VARCHAR(72) NOT NULL, - alias_key VARCHAR(72) NOT NULL, - alias VARCHAR(72) NOT NULL, - UNIQUE (symbol, alias_key, alias) -); - -CREATE TABLE IF NOT EXISTS api_key -( - id SERIAL PRIMARY KEY, - user_id VARCHAR(36) NOT NULL, - label VARCHAR(200) NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expiration_time TIMESTAMP, - allowed_ips TEXT, - token_expiration_time TIMESTAMP NOT NULL, - key VARCHAR(36) NOT NULL UNIQUE, - is_enabled BOOLEAN NOT NULL DEFAULT true, - is_expired BOOLEAN NOT NULL DEFAULT false -); diff --git a/api/api-ports/api-persister-postgres/src/test/kotlin/co/nilin/opex/api/ports/postgres/impl/sample/Samples.kt b/api/api-ports/api-persister-postgres/src/test/kotlin/co/nilin/opex/api/ports/postgres/impl/sample/Samples.kt index b6fc9e909..82e2905ba 100644 --- a/api/api-ports/api-persister-postgres/src/test/kotlin/co/nilin/opex/api/ports/postgres/impl/sample/Samples.kt +++ b/api/api-ports/api-persister-postgres/src/test/kotlin/co/nilin/opex/api/ports/postgres/impl/sample/Samples.kt @@ -1,9 +1,6 @@ package co.nilin.opex.api.ports.postgres.impl.sample import co.nilin.opex.api.ports.postgres.model.SymbolMapModel -import java.security.Principal -import java.time.LocalDateTime -import java.time.ZoneOffset object VALID { diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/config/ProxyDispatchers.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/config/ProxyDispatchers.kt index b82f269d9..1dbbda2aa 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/config/ProxyDispatchers.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/config/ProxyDispatchers.kt @@ -5,7 +5,7 @@ import reactor.core.scheduler.Schedulers object ProxyDispatchers { - val general = Schedulers.newBoundedElastic(10, 20, "general").asCoroutineDispatcher() - val market = Schedulers.newBoundedElastic(30, 60, "market").asCoroutineDispatcher() + val general = Schedulers.newBoundedElastic(8, 16, "general").asCoroutineDispatcher() + val market = Schedulers.newBoundedElastic(8, 16, "market").asCoroutineDispatcher() val wallet = Schedulers.newBoundedElastic(10, 20, "wallet").asCoroutineDispatcher() } \ No newline at end of file diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/AssignAddressRequest.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/AssignAddressRequest.kt deleted file mode 100644 index 2a3119e9c..000000000 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/AssignAddressRequest.kt +++ /dev/null @@ -1,7 +0,0 @@ -package co.nilin.opex.api.ports.proxy.data - -data class AssignAddressRequest( - val uuid: String, - val currency: String, - val chain: String, -) \ No newline at end of file diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/TransactionRequest.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/TransactionRequest.kt index 33a6640f4..b1d3debb5 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/TransactionRequest.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/TransactionRequest.kt @@ -1,10 +1,10 @@ package co.nilin.opex.api.ports.proxy.data data class TransactionRequest( - val coin: String?, - val startTime: Long?=null, - val endTime: Long?=null, - val limit: Int, - val offset: Int, - val ascendingByTime: Boolean? = false + val currency: String?, + val startTime: Long? = null, + val endTime: Long? = null, + val limit: Int?, + val offset: Int?, + val ascendingByTime: Boolean? = false ) \ No newline at end of file diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/WithdrawTransactionRequest.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/WithdrawTransactionRequest.kt new file mode 100644 index 000000000..63353a9b8 --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/WithdrawTransactionRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.ports.proxy.data + +import co.nilin.opex.api.core.inout.WithdrawStatus + +data class WithdrawTransactionRequest( + val currency: String?, + val startTime: Long? = null, + val endTime: Long? = null, + val limit: Int?, + val offset: Int?, + val ascendingByTime: Boolean? = false, + val status: WithdrawStatus? = null, +) \ 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/AccountantProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AccountantProxyImpl.kt index 18db21431..a712bc771 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AccountantProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AccountantProxyImpl.kt @@ -1,28 +1,35 @@ package co.nilin.opex.api.ports.proxy.impl -import co.nilin.opex.api.core.inout.PairFeeResponse -import co.nilin.opex.api.core.inout.PairInfoResponse +import co.nilin.opex.api.core.inout.FeeConfig +import co.nilin.opex.api.core.inout.PairConfigResponse +import co.nilin.opex.api.core.inout.UserFee +import co.nilin.opex.api.core.inout.WithdrawLimitConfig +import co.nilin.opex.api.core.inout.analytics.DailyAmount import co.nilin.opex.api.core.spi.AccountantProxy import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers +import co.nilin.opex.common.utils.Interval import co.nilin.opex.common.utils.LoggerDelegate import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.bodyToFlux import org.springframework.web.reactive.function.client.bodyToMono +import org.springframework.web.util.UriComponentsBuilder +import java.math.BigDecimal @Component -class AccountantProxyImpl(private val webClient: WebClient) : AccountantProxy { +class AccountantProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : AccountantProxy { private val logger by LoggerDelegate() @Value("\${app.accountant.url}") private lateinit var baseUrl: String - override suspend fun getPairConfigs(): List { + override suspend fun getPairConfigs(): List { logger.info("fetching pair configs") return withContext(ProxyDispatchers.general) { webClient.get() @@ -30,13 +37,13 @@ class AccountantProxyImpl(private val webClient: WebClient) : AccountantProxy { .accept(MediaType.APPLICATION_JSON) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToFlux() + .bodyToFlux() .collectList() .awaitSingle() } } - override suspend fun getFeeConfigs(): List { + override suspend fun getFeeConfigs(): List { logger.info("fetching fee configs") return withContext(ProxyDispatchers.general) { webClient.get() @@ -44,21 +51,138 @@ class AccountantProxyImpl(private val webClient: WebClient) : AccountantProxy { .accept(MediaType.APPLICATION_JSON) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToFlux() + .bodyToFlux() .collectList() .awaitSingle() } } - override suspend fun getFeeConfig(symbol: String): PairFeeResponse { - logger.info("fetching fee configs for $symbol") + + override suspend fun getWithdrawLimitConfigs(): List { + logger.info("fetching withdraw limit configs") return withContext(ProxyDispatchers.general) { webClient.get() - .uri("$baseUrl/config/fee/$symbol") + .uri("$baseUrl/config/withdraw-limit") .accept(MediaType.APPLICATION_JSON) .retrieve() - .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToMono() + .onStatus({ it.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitSingle() + } + } + + override suspend fun getTotalWithdrawVolumeValue(uuid: String, interval: Interval?): BigDecimal { + logger.info("fetching total withdraw volume value") + val uriBuilder = UriComponentsBuilder.fromUriString("$baseUrl/user/data/withdraw/volume/total/$uuid") + interval?.let { uriBuilder.queryParam("interval", it.toString()) } + val uri = uriBuilder.build().toUri() + + return withContext(ProxyDispatchers.general) { + webClient.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun getUserFee(uuid: String): UserFee { + logger.info("fetching user fee") + return withContext(ProxyDispatchers.general) { + webClient.get() + .uri("$baseUrl/user/data/fee/$uuid") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun getTradeVolumeByCurrency(uuid: String, symbol: String, interval: Interval): BigDecimal { + logger.info("fetching total trade volume by currency") + val uriBuilder = UriComponentsBuilder.fromUriString("$baseUrl/user/data/trade/volume/$uuid") + interval?.let { + uriBuilder.queryParam("interval", it.toString()) + uriBuilder.queryParam("symbol", symbol) + } + val uri = uriBuilder.build().toUri() + + return withContext(ProxyDispatchers.general) { + webClient.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun getTotalTradeVolumeValue(uuid: String, interval: Interval): BigDecimal { + + logger.info("fetching total trade volume value") + val uriBuilder = UriComponentsBuilder.fromUriString("$baseUrl/user/data/trade/volume/total/$uuid") + interval?.let { uriBuilder.queryParam("interval", it.toString()) } + val uri = uriBuilder.build().toUri() + + return withContext(ProxyDispatchers.general) { + webClient.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun getDailyWithdrawLast31Days( + uuid: String): List { + + logger.info("fetching daily withdraw stats for {}", uuid) + return withContext(ProxyDispatchers.general) { + webClient.get() + .uri("$baseUrl/user-activity/withdraw/$uuid") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .bodyToMono>() + .awaitSingle() + } + } + + override suspend fun getDailyDepositLast31Days( + uuid: String): List { + + logger.info("fetching daily deposit stats for {}", uuid) + + return withContext(ProxyDispatchers.general) { + webClient.get() + .uri("$baseUrl/user-activity/deposit/$uuid") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .bodyToMono>() + .awaitSingle() + } + } + + override suspend fun getDailyTradeLast31Days( + uuid: String): List { + + logger.info("fetching daily trade stats for {}", uuid) + + return withContext(ProxyDispatchers.general) { + webClient.get() + .uri("$baseUrl/user-activity/trade/$uuid") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .bodyToMono>() .awaitSingle() } } diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AuthProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AuthProxyImpl.kt new file mode 100644 index 000000000..ca831aa51 --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AuthProxyImpl.kt @@ -0,0 +1,226 @@ +package co.nilin.opex.api.ports.proxy.impl + +import co.nilin.opex.api.core.inout.auth.* +import co.nilin.opex.api.core.spi.AuthProxy +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitFirstOrElse +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBodilessEntity +import org.springframework.web.reactive.function.client.body +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + +@Component +class AuthProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : AuthProxy { + + private val logger by LoggerDelegate() + + @Value("\${app.auth-gateway.url}") + private lateinit var baseUrl: String + + override suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get token") } + } + + override suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token/confirm") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to confirm token") } + } + + override suspend fun resendLoginOtp( + request: ResendOtpRequest, + token: String + ): ResendOtpResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token/resend-otp") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to resend otp") } + } + + override suspend fun getToken(request: ExternalIdpTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token-external") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get token") } + } + + override suspend fun refreshToken(request: RefreshTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/refresh") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to refresh token") } + } + + override suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/register") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to register user") } + } + + override suspend fun verifyRegister(request: VerifyOTPRequest): OTPActionTokenResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/register/verify") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to verify register") } + } + + override suspend fun confirmRegister(request: ConfirmRegisterRequest): Token { + return webClient.post() + .uri("$baseUrl/v1/user/public/register/ccofirm") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to confirm register") } + } + + override suspend fun registerExternalIdpUser(request: ExternalIdpUserRegisterRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/register-external") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to register user") } + } + + override suspend fun forgetPassword(request: ForgotPasswordRequest): TempOtpResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/forget") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to forget password") } + } + + override suspend fun verifyForget(request: VerifyOTPRequest): OTPActionTokenResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/forget/verify") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to verify forget password") } + } + + override suspend fun confirmForget(request: ConfirmForgetRequest) { + webClient.post() + .uri("$baseUrl/v1/user/public/forget/confirm") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun logout(token: String) { + webClient.post() + .uri("$baseUrl/v1/user/logout") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun logout(sessionId: String, token: String) { + webClient.delete() + .uri("$baseUrl/v1/user/session/$sessionId") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getSessions( + request: SessionRequest, + token: String + ): List { + return webClient.post() + .uri("$baseUrl/v1/user/session") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get sessions") } + } + + override suspend fun logoutOthers(token: String) { + webClient.post() + .uri("$baseUrl/v1/user/session/delete-others") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun logoutAll(token: String) { + webClient.post() + .uri("$baseUrl/v1/user/session/delete-all") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } +} \ 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/BinanceGlobalMarketProxy.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BinanceGlobalMarketProxy.kt index 25f79f59c..616e3a55d 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BinanceGlobalMarketProxy.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BinanceGlobalMarketProxy.kt @@ -3,24 +3,21 @@ package co.nilin.opex.api.ports.proxy.impl import co.nilin.opex.api.core.inout.GlobalPrice import co.nilin.opex.api.core.spi.GlobalMarketProxy import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers -import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.withContext import org.springframework.beans.factory.annotation.Value -import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType import org.springframework.stereotype.Component -import org.springframework.web.reactive.function.client.WebClient -import org.springframework.web.reactive.function.client.bodyToFlux +import org.springframework.web.client.RestTemplate +import org.springframework.web.client.getForEntity import org.springframework.web.util.UriComponentsBuilder import java.net.URLEncoder @Component class BinanceGlobalMarketProxy( @Value("\${app.binance.api-url}") - val baseUrl: String + private val baseUrl: String, ) : GlobalMarketProxy { - private val webClient = WebClient.builder().build() + private val restTemplate = RestTemplate() override suspend fun getPrices(symbols: List): List { // Binance encoding requires to change some of the Java's encoding model @@ -32,15 +29,7 @@ class BinanceGlobalMarketProxy( .toUri() return withContext(ProxyDispatchers.general) { - webClient.get() - .uri(uri) - .accept(MediaType.APPLICATION_JSON) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .retrieve() - .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToFlux() - .collectList() - .awaitFirstOrElse { emptyList() } + restTemplate.getForEntity>(uri).body?.toList() ?: emptyList() } } } \ 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/BlockchainGatewayProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BlockchainGatewayProxyImpl.kt index bc3fa6814..5cecfadaa 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BlockchainGatewayProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BlockchainGatewayProxyImpl.kt @@ -1,41 +1,40 @@ package co.nilin.opex.api.ports.proxy.impl -import co.nilin.opex.api.core.inout.AssignResponse -import co.nilin.opex.api.core.inout.CurrencyImplementation -import co.nilin.opex.api.core.inout.DepositDetails +import co.nilin.opex.api.core.inout.* import co.nilin.opex.api.core.spi.BlockchainGatewayProxy import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers -import co.nilin.opex.api.ports.proxy.data.AssignAddressRequest import co.nilin.opex.api.ports.proxy.data.DepositDetailsRequest +import co.nilin.opex.common.OpexError import co.nilin.opex.common.utils.LoggerDelegate import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.stereotype.Component -import org.springframework.web.reactive.function.client.WebClient -import org.springframework.web.reactive.function.client.body -import org.springframework.web.reactive.function.client.bodyToFlux +import org.springframework.web.reactive.function.client.* import reactor.core.publisher.Mono import java.net.URI @Component -class BlockchainGatewayProxyImpl(private val client: WebClient) : BlockchainGatewayProxy { +class BlockchainGatewayProxyImpl(@Qualifier("generalWebClient") private val client: WebClient) : + BlockchainGatewayProxy { private val logger by LoggerDelegate() @Value("\${app.opex-bc-gateway.url}") private lateinit var baseUrl: String - override suspend fun assignAddress(uuid: String, currency: String, chain: String): AssignResponse? { + override suspend fun assignAddress(assignAddressRequest: AssignAddressRequest): AssignResponse? { logger.info("calling bc-gateway assign") return withContext(ProxyDispatchers.general) { client.post() .uri(URI.create("$baseUrl/v1/address/assign")) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) - .body(Mono.just(AssignAddressRequest(uuid, currency, chain))) + .body(Mono.just(assignAddressRequest)) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) .bodyToMono(AssignResponse::class.java) @@ -59,19 +58,73 @@ class BlockchainGatewayProxyImpl(private val client: WebClient) : BlockchainGate } } - override suspend fun getCurrencyImplementations(currency: String?): List { - logger.info("calling bc-gateway chain details") + override suspend fun getChainInfo(): List { + logger.info("calling bc-gateway chains info") return withContext(ProxyDispatchers.general) { client.get() - .uri("$baseUrl/currency/chains") { - it.queryParam("currency", currency) - it.build() - }.accept(MediaType.APPLICATION_JSON) + .uri(URI.create("$baseUrl/crypto-currency/chain")) + .accept(MediaType.APPLICATION_JSON) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToFlux() + .bodyToFlux() .collectList() .awaitFirstOrElse { emptyList() } } } + + override suspend fun getOnChainGatewayLocalizations( + token: String, + gatewayUuid: String + ): GatewayLocalizationResponse { + return client.get() + .uri("$baseUrl/crypto-currency/gateway/${gatewayUuid}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun saveOnChainGatewayLocalizations( + token: String, + gatewayUuid: String, + gatewayLocalizations: List + ): GatewayLocalizationResponse { + return client.post() + .uri("$baseUrl/crypto-currency/gateway/${gatewayUuid}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(gatewayLocalizations)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun deleteOnChainGatewayLocalization(token: String, id: Long) { + client.delete() + .uri("$baseUrl/crypto-currency/gateway/localization/${id}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + +// override suspend fun getCurrencyImplementations(currency: String?): List { +// logger.info("calling bc-gateway chain details") +// return client.get() +// .uri("$baseUrl/currency/chains") { +// it.queryParam("currency", currency) +// it.build() +// }.accept(MediaType.APPLICATION_JSON) +// .retrieve() +// .onStatus({ t -> t.isError }, { it.createException() }) +// .bodyToFlux() +// .collectList() +// .awaitFirstOrElse { emptyList() } +// } } \ 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/ConfigProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ConfigProxyImpl.kt new file mode 100644 index 000000000..7d3e19192 --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ConfigProxyImpl.kt @@ -0,0 +1,170 @@ +package co.nilin.opex.api.ports.proxy.impl + +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.common.OpexError +import co.nilin.opex.common.data.UserLanguage +import co.nilin.opex.common.data.WebConfig +import co.nilin.opex.common.utils.LanguageUtils.getUserLanguage +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.* +import reactor.core.publisher.Mono + +@Component +class ConfigProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : ConfigProxy { + + private val logger by LoggerDelegate() + + @Value("\${app.config.url}") + private lateinit var baseUrl: String + + override suspend fun getWebConfig(): WebConfig { + return webClient.get() + .uri("$baseUrl/web/v1") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get web config") } + } + + override suspend fun updateWebConfig( + token: String, + request: UpdateWebConfigRequest + ): WebConfig { + return webClient.post() + .uri("$baseUrl/web/v1") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getUserLevelConfig(): List { + val lang = UserLanguage.safeValueOf(getUserLanguage().awaitSingleOrNull()).toString() + return webClient.get() + .uri("$baseUrl/user-level/v1") { + it.queryParam("language", lang) + it.build() + } + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitSingle() + } + + override suspend fun updateUserLevelConfig( + token: String, + userLevelConfig: UserLevelConfig + ): UserLevelConfig { + return webClient.post() + .uri("$baseUrl/user-level/v1") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(userLevelConfig)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun deleteUserLevelConfig( + token: String, + userLevel: String, + language: String + ) { + webClient.delete() + .uri("$baseUrl/user-level/v1/${userLevel}/${language}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getUserConfig(token: String): UserWebConfig { + return webClient.get() + .uri("$baseUrl/user/v1") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get user config") } + } + + override suspend fun updateUserConfig( + token: String, + request: UpdateUserConfigRequest + ): UserWebConfig { + return webClient.post() + .uri("$baseUrl/user/v1") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getUserFavoritePair(token: String): Set { + return webClient.get() + .uri("$baseUrl/user/v1/pair") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get user favorite pair") } + } + + override suspend fun addUserFavoritePair( + token: String, + pair: String + ): Set { + return webClient.post() + .uri("$baseUrl/user/v1/pair/${pair}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun removeUserFavoritePair( + token: String, + pair: String + ): Set { + return webClient.method(HttpMethod.DELETE) + .uri("$baseUrl/user/v1/pair/${pair}") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } +} + diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt index 742a1a848..41782c441 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt @@ -8,17 +8,20 @@ import co.nilin.opex.common.utils.LoggerDelegate import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.body import org.springframework.web.reactive.function.client.bodyToFlux import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono import java.util.* @Component -class MarketDataProxyImpl(private val webClient: WebClient) : MarketDataProxy { +class MarketDataProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : MarketDataProxy { private val logger by LoggerDelegate() @@ -256,4 +259,36 @@ class MarketDataProxyImpl(private val webClient: WebClient) : MarketDataProxy { ?.value ?: 0 } } + + override suspend fun recentTrades(token: String, request: AdminTradesHistoryRequest): List { + logger.info("admin recent trades wrapper for symbol=${request.baseAsset}-${request.quoteAsset}") + return withContext(ProxyDispatchers.market) { + webClient.post() + .uri("$baseUrl/v1/admin/trades/history") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun recentOrders(token: String, request: AdminOrdersHistoryRequest): List { + logger.info("admin recent orders wrapper for symbol=${request.symbol}") + return withContext(ProxyDispatchers.market) { + webClient.post() + .uri("$baseUrl/v1/admin/orders/history") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } } \ 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/MarketStatProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketStatProxyImpl.kt index 369ac556e..532b8e0bc 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketStatProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketStatProxyImpl.kt @@ -8,6 +8,7 @@ import co.nilin.opex.common.utils.Interval import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -18,6 +19,7 @@ import org.springframework.web.reactive.function.client.bodyToMono @Component class MarketStatProxyImpl( + @Qualifier("generalWebClient") private val webClient: WebClient, @Value("\${app.market.url}") private val baseUrl: String diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketUserDataProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketUserDataProxyImpl.kt index 20abf1a99..b40cfdc03 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketUserDataProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketUserDataProxyImpl.kt @@ -1,16 +1,20 @@ package co.nilin.opex.api.ports.proxy.impl -import co.nilin.opex.api.core.inout.Order -import co.nilin.opex.api.core.inout.Trade +import co.nilin.opex.api.core.inout.* import co.nilin.opex.api.core.spi.MarketUserDataProxy import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers import co.nilin.opex.api.ports.proxy.data.AllOrderRequest import co.nilin.opex.api.ports.proxy.data.QueryOrderRequest import co.nilin.opex.api.ports.proxy.data.TradeRequest import co.nilin.opex.common.utils.LoggerDelegate +import io.netty.handler.timeout.TimeoutException +import kotlinx.coroutines.delay import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -24,24 +28,33 @@ import java.security.Principal import java.util.* @Component -class MarketUserDataProxyImpl(private val webClient: WebClient) : MarketUserDataProxy { +class MarketUserDataProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : MarketUserDataProxy { private val logger by LoggerDelegate() @Value("\${app.market.url}") private lateinit var baseUrl: String + private suspend fun retryOnce(backoffMs: Long = 200, block: suspend () -> T): T = + try { + block() + } catch (e: TimeoutException) { + delay(backoffMs); block() + } + + private val mgLimiter = Semaphore(permits = 16, acquiredPermits = 0) override suspend fun queryOrder( - principal: Principal, + token: String, symbol: String, orderId: Long?, - origClientOrderId: String? + origClientOrderId: String?, ): Order? { return withContext(ProxyDispatchers.market) { webClient.post() - .uri("$baseUrl/v1/user/${principal.name}/order/query") + .uri("$baseUrl/v1/user/order/query") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) .body(Mono.just(QueryOrderRequest(symbol, orderId, origClientOrderId))) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) @@ -50,34 +63,40 @@ class MarketUserDataProxyImpl(private val webClient: WebClient) : MarketUserData } } - override suspend fun openOrders(principal: Principal, symbol: String?, limit: Int?): List { + override suspend fun openOrders(token: String, symbol: String?, limit: Int?): List { return withContext(ProxyDispatchers.market) { - webClient.get() - .uri("$baseUrl/v1/user/${principal.name}/orders/$symbol/open") { - it.queryParam("limit", limit ?: 100) - it.build() - }.accept(MediaType.APPLICATION_JSON) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .retrieve() - .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToFlux() - .collectList() - .awaitFirstOrElse { emptyList() } + mgLimiter.withPermit { + retryOnce { + webClient.get() + .uri("$baseUrl/v1/user/orders/$symbol/open") { + it.queryParam("limit", limit ?: 100) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } } } override suspend fun allOrders( - principal: Principal, + token: String, symbol: String?, startTime: Date?, endTime: Date?, - limit: Int? + limit: Int?, ): List { return withContext(ProxyDispatchers.market) { webClient.post() - .uri("$baseUrl/v1/user/${principal.name}/orders") + .uri("$baseUrl/v1/user/orders") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) .body(Mono.just(AllOrderRequest(symbol, startTime, endTime, limit ?: 500))) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) @@ -88,18 +107,19 @@ class MarketUserDataProxyImpl(private val webClient: WebClient) : MarketUserData } override suspend fun allTrades( - principal: Principal, + token: String, symbol: String?, fromTrade: Long?, startTime: Date?, endTime: Date?, - limit: Int? + limit: Int?, ): List { return withContext(ProxyDispatchers.market) { webClient.post() - .uri("$baseUrl/v1/user/${principal.name}/trades") + .uri("$baseUrl/v1/user/trades") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) .body(Mono.just(TradeRequest(symbol, fromTrade, startTime, endTime, limit ?: 500))) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) @@ -109,4 +129,117 @@ class MarketUserDataProxyImpl(private val webClient: WebClient) : MarketUserData } } + override suspend fun getOrderHistory( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List { + return withContext(ProxyDispatchers.market) { + webClient.get() + .uri("$baseUrl/v1/user/order/history") { + it.queryParam("symbol", symbol) + it.queryParam("startTime", startTime) + it.queryParam("endTime", endTime) + it.queryParam("orderType", orderType) + it.queryParam("direction", direction) + it.queryParam("limit", limit) + it.queryParam("offset", offset) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getOrderHistoryCount( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + ): Long { + return withContext(ProxyDispatchers.market) { + webClient.get() + .uri("$baseUrl/v1/user/order/history/count") { + it.queryParam("symbol", symbol) + it.queryParam("startTime", startTime) + it.queryParam("endTime", endTime) + it.queryParam("orderType", orderType) + it.queryParam("direction", direction) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { 0L } + } + } + + override suspend fun getTradeHistory( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List { + return withContext(ProxyDispatchers.market) { + webClient.get() + .uri("$baseUrl/v1/user/trade/history") { + it.queryParam("symbol", symbol) + it.queryParam("startTime", startTime) + it.queryParam("endTime", endTime) + it.queryParam("direction", direction) + it.queryParam("limit", limit) + it.queryParam("offset", offset) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getTradeHistoryCount( + token: String, + symbol: String?, + startTime: Long?, + endTime: Long?, + direction: OrderDirection?, + ): Long { + return withContext(ProxyDispatchers.market) { + webClient.get() + .uri("$baseUrl/v1/user/trade/history/count") { + it.queryParam("symbol", symbol) + it.queryParam("startTime", startTime) + it.queryParam("endTime", endTime) + it.queryParam("direction", direction) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token" ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { 0L } + } + } } \ 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/MatchingGatewayProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MatchingGatewayProxyImpl.kt index 464b41ebd..a0718dba0 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MatchingGatewayProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MatchingGatewayProxyImpl.kt @@ -1,34 +1,45 @@ package co.nilin.opex.api.ports.proxy.impl -import co.nilin.opex.api.core.inout.MatchConstraint -import co.nilin.opex.api.core.inout.MatchingOrderType -import co.nilin.opex.api.core.inout.OrderDirection -import co.nilin.opex.api.core.inout.OrderSubmitResult +import co.nilin.opex.api.core.inout.* import co.nilin.opex.api.core.spi.MatchingGatewayProxy import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers import co.nilin.opex.api.ports.proxy.data.CancelOrderRequest import co.nilin.opex.api.ports.proxy.data.CreateOrderRequest import co.nilin.opex.common.utils.LoggerDelegate +import io.netty.handler.timeout.TimeoutException +import kotlinx.coroutines.delay +import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.body +import org.springframework.web.reactive.function.client.bodyToFlux import org.springframework.web.reactive.function.client.bodyToMono import reactor.core.publisher.Mono import java.math.BigDecimal import java.net.URI @Component -class MatchingGatewayProxyImpl(private val client: WebClient) : MatchingGatewayProxy { +class MatchingGatewayProxyImpl(@Qualifier("generalWebClient") private val client: WebClient) : MatchingGatewayProxy { private val logger by LoggerDelegate() + private suspend fun retryOnce(backoffMs: Long = 200, block: suspend () -> T): T = + try { + block() + } catch (e: TimeoutException) { + delay(backoffMs); block() + } @Value("\${app.matching-gateway.url}") private lateinit var baseUrl: String - + private val mgLimiter = Semaphore(permits = 16, acquiredPermits = 0) // fair-like behavior override suspend fun createNewOrder( uuid: String?, pair: String, @@ -38,23 +49,26 @@ class MatchingGatewayProxyImpl(private val client: WebClient) : MatchingGatewayP matchConstraint: MatchConstraint?, orderType: MatchingOrderType, userLevel: String, - token: String? + token: String?, ): OrderSubmitResult? { logger.info("calling matching-gateway order create") val body = CreateOrderRequest(uuid, pair, price, quantity, direction, matchConstraint, orderType, userLevel) return withContext(ProxyDispatchers.general) { - client.post() - .uri(URI.create("$baseUrl/order")) - .accept(MediaType.APPLICATION_JSON) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer $token") - .body(Mono.just(body)) - .retrieve() - .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToMono() - .awaitSingleOrNull() + mgLimiter.withPermit { + retryOnce { + client.post() + .uri(URI.create("$baseUrl/order")) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer $token") + .body(Mono.just(body)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingleOrNull() + } + } } - } override suspend fun cancelOrder( @@ -62,7 +76,7 @@ class MatchingGatewayProxyImpl(private val client: WebClient) : MatchingGatewayP uuid: String, orderId: Long, symbol: String, - token: String? + token: String?, ): OrderSubmitResult? { logger.info("calling matching-gateway order cancel") return withContext(ProxyDispatchers.general) { @@ -78,4 +92,18 @@ class MatchingGatewayProxyImpl(private val client: WebClient) : MatchingGatewayP .awaitSingleOrNull() } } + + override suspend fun getPairSettings(): List { + return withContext(ProxyDispatchers.wallet) { + client.get() + .uri("$baseUrl/pair-setting") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } } \ 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 new file mode 100644 index 000000000..7f9bff866 --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt @@ -0,0 +1,296 @@ +package co.nilin.opex.api.ports.proxy.impl + +import co.nilin.opex.api.core.inout.CompleteProfileRequest +import co.nilin.opex.api.core.inout.ContactUpdateConfirmRequest +import co.nilin.opex.api.core.inout.ContactUpdateRequest +import co.nilin.opex.api.core.inout.ProfileApprovalRequestUser +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.spi.ProfileProxy +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitFirstOrElse +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBodilessEntity +import org.springframework.web.reactive.function.client.body +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + +@Component +class ProfileProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : ProfileProxy { + + private val logger by LoggerDelegate() + + @Value("\${app.profile.url}") + private lateinit var baseUrl: String + + override suspend fun getProfiles( + token: String, + profileRequest: ProfileRequest, + ): List { + return webClient.post() + .uri("$baseUrl/admin/profile") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(profileRequest)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get profiles") } + } + + override suspend fun getProfileAdmin(token: String, uuid: String): Profile { + return webClient.get() + .uri("$baseUrl/admin/profile/$uuid") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get profile of $uuid") } + } + + override suspend fun getProfileHistory( + token: String, + uuid: String, + limit: Int, + offset: Int + ): List { + return webClient.get() + .uri("$baseUrl/admin/profile/history/$uuid?limit=$limit&offset=$offset") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get profile history of $uuid") } + } + + override suspend fun getProfileApprovalRequests( + token: String, + request: ProfileApprovalRequestFilter + ): List { + return webClient.post() + .uri("$baseUrl/admin/profile/approval-requests") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get profile approval requests") } + } + + override suspend fun getProfileApprovalRequest( + token: String, + requestId: Long + ): ProfileApprovalAdminResponse { + return webClient.get() + .uri("$baseUrl/admin/profile/approval-request/$requestId") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get profile approval request $requestId") } + } + + override suspend fun updateProfileApprovalRequest( + token: String, + request: UpdateApprovalRequestBody + ): ProfileApprovalAdminResponse { + return webClient.put() + .uri("$baseUrl/admin/profile/approval-request") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to update profile approval request ${request.id}") } + } + + override suspend fun resolveUsers(token: String, request: ResolveUsersRequest): Map { + return try { + webClient.post() + .uri("$baseUrl/admin/profile/users/resolve") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono(object : org.springframework.core.ParameterizedTypeReference>() {}) + .awaitFirstOrElse { emptyMap() } + } catch (t: Throwable) { + emptyMap() + } + } + + override suspend fun addAddressBook( + token: String, + request: AddAddressBookItemRequest + ): AddressBookResponse { + return webClient.post() + .uri("$baseUrl/address-book") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to add address book") } + } + + override suspend fun getAllAddressBooks(token: String): List { + return webClient.get() + .uri("$baseUrl/address-book") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get address books") } + } + + override suspend fun deleteAddressBook( + token: String, + id: Long + ) { + webClient.delete() + .uri("$baseUrl/address-book/$id") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun updateAddressBook( + token: String, + id: Long, + request: AddAddressBookItemRequest + ): AddressBookResponse { + return webClient.put() + .uri("$baseUrl/address-book/$id") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to update address book") } + } + + override suspend fun addBankAccount( + token: String, + request: AddBankAccountRequest + ): BankAccountResponse { + return webClient.post() + .uri("$baseUrl/bank-account") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to add bank account") } + } + + override suspend fun getBankAccounts(token: String): List { + return webClient.get() + .uri("$baseUrl/bank-account") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get bank accounts") } + } + + override suspend fun deleteBankAccount(token: String, id: Long) { + webClient.delete() + .uri("$baseUrl/bank-account/$id") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getProfile(token: String): Profile { + return webClient.get() + .uri("$baseUrl/personal-data") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get profile") } + } + + override suspend fun completeProfile( + token: String, + request: CompleteProfileRequest + ): Profile? { + return webClient.put() + .uri("$baseUrl/completion") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to complete profile") } + } + + override suspend fun requestContactUpdate( + token: String, + request: ContactUpdateRequest + ): TempOtpResponse { + return webClient.post() + .uri("$baseUrl/contact/update/otp-request") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to request contact update") } + } + + override suspend fun confirmContactUpdate( + token: String, + request: ContactUpdateConfirmRequest + ) { + webClient.patch() + .uri("$baseUrl/contact/update/otp-verification") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .bodyValue(request) + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getUserProfileApprovalRequest(token: String): ProfileApprovalRequestUser { + return webClient.get() + .uri("$baseUrl/approval-request") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get profile approval request") } + } +} + diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/RateProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/RateProxyImpl.kt new file mode 100644 index 000000000..0258151b9 --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/RateProxyImpl.kt @@ -0,0 +1,196 @@ +package co.nilin.opex.api.ports.proxy.impl + +import co.nilin.opex.api.core.inout.otc.* +import co.nilin.opex.api.core.spi.RateProxy +import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.body +import org.springframework.web.reactive.function.client.bodyToFlux +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + +@Component +class RateProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : RateProxy { + + private val logger by LoggerDelegate() + + @Value("\${app.wallet.url}") + private lateinit var baseUrl: String + + // Rates + override suspend fun createRate(token: String, request: SetCurrencyExchangeRateRequest) { + withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/otc/rate") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .toBodilessEntity() + .awaitFirstOrElse { null } + } + } + + override suspend fun updateRate(token: String, request: SetCurrencyExchangeRateRequest): Rates { + return withContext(ProxyDispatchers.wallet) { + webClient.put() + .uri("$baseUrl/otc/rate") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun deleteRate(token: String, sourceSymbol: String, destSymbol: String): Rates { + return withContext(ProxyDispatchers.wallet) { + webClient.delete() + .uri("$baseUrl/otc/rate/$sourceSymbol/$destSymbol") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchRates(): Rates { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/rate") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchRate(sourceSymbol: String, destSymbol: String): Rate? { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/rate/$sourceSymbol/$destSymbol") + .retrieve() + .bodyToMono() + .awaitFirstOrNull() + } + } + + // Forbidden pairs + override suspend fun addForbiddenPair(token: String, request: CurrencyPair) { + withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/otc/forbidden-pairs") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .toBodilessEntity() + .awaitFirstOrElse { null } + } + } + + override suspend fun deleteForbiddenPair(token: String, sourceSymbol: String, destSymbol: String): ForbiddenPairs { + return withContext(ProxyDispatchers.wallet) { + webClient.delete() + .uri("$baseUrl/otc/forbidden-pairs/$sourceSymbol/$destSymbol") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchForbiddenPairs(): ForbiddenPairs { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/forbidden-pairs") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + // Transitive symbols + override suspend fun addTransitiveSymbols(token: String, symbols: Symbols) { + withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/otc/transitive-symbols") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(symbols)) + .retrieve() + .toBodilessEntity() + .awaitFirstOrElse { null } + } + } + + override suspend fun deleteTransitiveSymbol(token: String, symbol: String): Symbols { + return withContext(ProxyDispatchers.wallet) { + webClient.delete() + .uri("$baseUrl/otc/transitive-symbols/$symbol") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun deleteTransitiveSymbols(token: String, symbols: Symbols): Symbols { + return withContext(ProxyDispatchers.wallet) { + webClient.method(HttpMethod.DELETE) + .uri("$baseUrl/otc/transitive-symbols") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(symbols)) + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchTransitiveSymbols(): Symbols { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/transitive-symbols") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + // Routes and prices + override suspend fun fetchRoutes(sourceSymbol: String?, destSymbol: String?): CurrencyExchangeRatesResponse { + var uri = "$baseUrl/otc/route" + if (sourceSymbol != null) uri = "$uri?sourceSymbol=$sourceSymbol" + if (destSymbol != null) uri = "$uri?destSymbol=$destSymbol" + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri(uri) + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun getPrice(unit: String): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/currency/price?unit=$unit") + .retrieve() + .bodyToFlux() + .collectList() + .awaitSingle() + } + } +} diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/StorageProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/StorageProxyImpl.kt new file mode 100644 index 000000000..8fc3aa64f --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/StorageProxyImpl.kt @@ -0,0 +1,121 @@ +package co.nilin.opex.api.ports.proxy.impl + +import co.nilin.opex.api.core.spi.StorageProxy +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitSingle +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.http.codec.multipart.FilePart +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBodilessEntity +import reactor.core.publisher.Mono + +@Component +class StorageProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : StorageProxy { + + private val logger by LoggerDelegate() + + @Value("\${app.storage.url}") + private lateinit var baseUrl: String + + override suspend fun adminDownload( + token: String, + bucket: String, + key: String + ): ResponseEntity { + return webClient.get() + .uri("$baseUrl/v2/admin") { + it.queryParam("bucket", bucket) + it.queryParam("key", key) + it.build() + } + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .accept( + MediaType.APPLICATION_OCTET_STREAM, + MediaType.APPLICATION_JSON + ) + .exchangeToMono { response -> + if (response.statusCode().isError) { + response.createException().flatMap { Mono.error(it) } + } else { + response.toEntity(ByteArray::class.java) + } + } + .awaitSingle() + } + + override suspend fun adminUpload( + token: String, + bucket: String, + key: String, + file: FilePart, + isPublic : Boolean? + ) { + webClient.post() + .uri("$baseUrl/v2/admin"){ + it.queryParam("isPublic", isPublic) + it.queryParam("bucket", bucket) + it.queryParam("key", key) + it.build() + } + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body( + BodyInserters.fromMultipartData( + LinkedMultiValueMap().apply { + add("file", file) + } + )) + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .awaitBodilessEntity() + } + + override suspend fun adminDelete( + token: String, + bucket: String, + key: String + ) { + webClient.delete() + .uri("$baseUrl/v2/admin") { + it.queryParam("bucket", bucket) + it.queryParam("key", key) + it.build() + } + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .awaitBodilessEntity() + } + + override suspend fun publicDownload( + bucket: String, + key: String + ): ResponseEntity { + return webClient.get() + .uri("$baseUrl/v2/public") { + it.queryParam("bucket", bucket) + it.queryParam("key", key) + it.build() + } + .accept( + MediaType.APPLICATION_OCTET_STREAM, + MediaType.APPLICATION_JSON + ) + .exchangeToMono { response -> + if (response.statusCode().isError) { + response.createException().flatMap { Mono.error(it) } + } else { + response.toEntity(ByteArray::class.java) + } + } + .awaitSingle() + } +} 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 cf38ab595..7e87050fb 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 @@ -1,28 +1,29 @@ package co.nilin.opex.api.ports.proxy.impl -import co.nilin.opex.api.core.inout.OwnerLimitsResponse -import co.nilin.opex.api.core.inout.TransactionHistoryResponse -import co.nilin.opex.api.core.inout.Wallet -import co.nilin.opex.api.core.inout.WithdrawHistoryResponse +import co.nilin.opex.api.core.inout.* +import co.nilin.opex.api.core.inout.analytics.DailyAmount import co.nilin.opex.api.core.spi.WalletProxy import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers import co.nilin.opex.api.ports.proxy.data.TransactionRequest +import co.nilin.opex.api.ports.proxy.data.WithdrawTransactionRequest +import co.nilin.opex.common.OpexError import co.nilin.opex.common.utils.LoggerDelegate import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod import org.springframework.http.MediaType import org.springframework.stereotype.Component -import org.springframework.web.reactive.function.client.WebClient -import org.springframework.web.reactive.function.client.body -import org.springframework.web.reactive.function.client.bodyToFlux -import org.springframework.web.reactive.function.client.bodyToMono +import org.springframework.web.reactive.function.client.* import reactor.core.publisher.Mono +import java.math.BigDecimal @Component -class WalletProxyImpl(private val webClient: WebClient) : WalletProxy { +class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : WalletProxy { private val logger by LoggerDelegate() @@ -74,53 +75,1073 @@ class WalletProxyImpl(private val webClient: WebClient) : WalletProxy { override suspend fun getDepositTransactions( uuid: String, - token: String?, - coin: String?, + token: String, + currency: String?, startTime: Long?, endTime: Long?, limit: Int, offset: Int, - ascendingByTime: Boolean? - ): List { + ascendingByTime: Boolean?, + ): List { logger.info("fetching deposit transaction history for $uuid") return withContext(ProxyDispatchers.wallet) { webClient.post() - .uri("$baseUrl/transaction/deposit/$uuid") + .uri("$baseUrl/v1/deposit/history") .accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") - .body(Mono.just(TransactionRequest(coin, startTime, endTime, limit, offset, ascendingByTime))) + .body(Mono.just(TransactionRequest(currency, startTime, endTime, limit, offset, ascendingByTime))) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToFlux() + .bodyToFlux() .collectList() .awaitFirstOrElse { emptyList() } } } + override suspend fun getDepositTransactionsCount( + uuid: String, + token: String, + currency: String?, + startTime: Long?, + endTime: Long?, + ): Long { + logger.info("fetching deposit transaction count for $uuid") + return withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/v1/deposit/history/count") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(TransactionRequest(currency, startTime, endTime, null, null))) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { 0L } + } + } + override suspend fun getWithdrawTransactions( uuid: String, - token: String?, - coin: String?, + token: String, + currency: String?, + staus: WithdrawStatus?, startTime: Long?, endTime: Long?, limit: Int, offset: Int, - ascendingByTime: Boolean? - ): List { + ascendingByTime: Boolean?, + ): List { logger.info("fetching withdraw transaction history for $uuid") return withContext(ProxyDispatchers.wallet) { webClient.post() - .uri("$baseUrl/withdraw/history/$uuid") + .uri("$baseUrl/withdraw/history") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body( + Mono.just( + WithdrawTransactionRequest( + currency, + startTime, + endTime, + limit, + offset, + ascendingByTime, + staus + ) + ) + ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getWithdrawTransactionsCount( + uuid: String, + token: String, + currency: String?, + status: WithdrawStatus?, + startTime: Long?, + endTime: Long?, + ): Long { + logger.info("fetching withdraw transaction count for $uuid") + return withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/withdraw/history/count") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(WithdrawTransactionRequest(currency, startTime, endTime, null, null, null, status))) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { 0L } + } + } + + override suspend fun getTransactions( + uuid: String, + token: String, + currency: String?, + category: UserTransactionCategory?, + startTime: Long?, + endTime: Long?, + limit: Int, + offset: Int, + ascendingByTime: Boolean? + ): List { + return webClient.post() + .uri("$baseUrl/v2/transaction") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body( + Mono.just( + UserTransactionRequest( + null, + currency, + null, + null, + category, + startTime, + endTime, + limit, + offset, + ascendingByTime == true, + ) + ) + ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun getTransactionsCount( + uuid: String, + token: String, + currency: String?, + category: UserTransactionCategory?, + startTime: Long?, + endTime: Long?, + ): Long { + return webClient.post() + .uri("$baseUrl/v2/transaction/count") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body( + Mono.just( + UserTransactionRequest( + null, + currency, + null, + null, + category, + startTime, + endTime, + null + ) + ) + ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { 0L } + } + + override suspend fun getGateWays( + includeOffChainGateways: Boolean, + includeOnChainGateways: Boolean, + ): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/currency/gateways") { + it.queryParam("includeOffChainGateways", includeOffChainGateways) + it.queryParam("includeOnChainGateways", includeOnChainGateways) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getCurrencies(): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/currency/all") .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getUserTradeTransactionSummary( + uuid: String, + token: String, + startTime: Long?, + endTime: Long?, + limit: Int?, + ): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/v2/transaction/trade/summary/$uuid") { + it.queryParam("startTime", startTime) + it.queryParam("endTime", endTime) + it.queryParam("limit", limit) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getUserDepositSummary( + uuid: String, + token: String, + startTime: Long?, + endTime: Long?, + limit: Int?, + ): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/deposit/summary/$uuid") { + it.queryParam("startTime", startTime) + it.queryParam("endTime", endTime) + it.queryParam("limit", limit) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getUserWithdrawSummary( + uuid: String, + token: String, + startTime: Long?, + endTime: Long?, + limit: Int?, + ): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/withdraw/summary/$uuid") { + it.queryParam("startTime", startTime) + it.queryParam("endTime", endTime) + it.queryParam("limit", limit) + it.build() + }.accept(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") - .body(Mono.just(TransactionRequest(coin, startTime, endTime, limit, offset, ascendingByTime))) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToFlux() + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun requestWithdraw( + token: String, + request: RequestWithdrawBody + ): WithdrawActionResult { + return webClient.post() + .uri("$baseUrl/withdraw") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun cancelWithdraw(token: String, withdrawUuid: String): Void? { + return webClient.post() + .uri("$baseUrl/withdraw/$withdrawUuid/cancel") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono(Void::class.java) + .awaitFirstOrNull() + } + + override suspend fun findWithdraw(token: String, withdrawUuid: String): WithdrawResponse { + return webClient.get() + .uri("$baseUrl/withdraw/$withdrawUuid") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.WithdrawNotFound.exception() } + } + + override suspend fun submitVoucher( + code: String, + token: String + ): SubmitVoucherResponse { + return webClient.put() + .uri("$baseUrl/voucher/$code") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getQuoteCurrencies(): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/currency/quotes") { + it.queryParam("isActive", true) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() .collectList() .awaitFirstOrElse { emptyList() } } } + override suspend fun getSwapTransactions(token: String, request: UserSwapTransactionRequest): List { + return webClient.post() + .uri("$baseUrl/v1/swap/history") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun getSwapTransactionsCount( + token: String, + request: UserSwapTransactionRequest + ): Long { + return webClient.post() + .uri("$baseUrl/v1/swap/history/count") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { 0L } + } + + override suspend fun requestWithdrawOTP( + token: String, + withdrawUuid: String, + otpType: OTPType + ): TempOtpResponse { + return webClient.post() + .uri("$baseUrl/withdraw/${withdrawUuid}/otp/${otpType}/request") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun verifyWithdrawOTP( + token: String, + withdrawUuid: String, + otpType: OTPType, + otpCode: String + ): WithdrawActionResult { + return webClient.post() + .uri("$baseUrl/withdraw/${withdrawUuid}/otp/${otpType}/verify?otpCode=${otpCode}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getWithdrawTransactionsForAdmin( + token: String, + request: AdminWithdrawHistoryRequest + ): List { + return webClient.post() + .uri("$baseUrl/admin/withdraw/history?offset=${request.offset}&size=${request.limit}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun getDepositTransactionsForAdmin( + token: String, + request: AdminDepositHistoryRequest + ): List { + return webClient.post() + .uri("$baseUrl/admin/deposit/history?offset=${request.offset}&size=${request.limit}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun getSwapTransactionsForAdmin( + token: String, + request: UserSwapTransactionRequest + ): List { + return webClient.post() + .uri("$baseUrl/admin/v1/swap/history") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun getTradeHistoryForAdmin( + token: String, + request: AdminTradeHistoryRequest + ): List { + return webClient.post() + .uri("$baseUrl/admin/v2/transaction/trades") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun getUserTransactionHistoryForAdmin( + token: String, + request: UserTransactionRequest + ): List { + return webClient.post() + .uri("$baseUrl/admin/v2/transaction/history") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun getUsersWallets( + token: String, + uuid: String?, + currency: String?, + excludeSystem: Boolean, + limit: Int, + offset: Int + ): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/stats/v2/wallets") { builder -> + uuid?.let { builder.queryParam("uuid", it) } + currency?.let { builder.queryParam("currency", it) } + builder.queryParam("excludeSystem", excludeSystem) + builder.queryParam("limit", limit) + builder.queryParam("offset", offset) + builder.build() + } + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get users wallets") } + } + } + + override suspend fun getSystemWalletsTotal(token: String): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/stats/wallets/system/total") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get system wallets total") } + } + } + + override suspend fun getUsersWalletsTotal(token: String): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/stats/wallets/user/total") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get users wallets total") } + } + } + + override suspend fun acceptWithdraw( + token: String, + withdrawUuid: String + ): WithdrawActionResult { + return webClient.post() + .uri("$baseUrl/admin/withdraw/${withdrawUuid}/accept") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun doneWithdraw( + token: String, + withdrawUuid: String, + request: WithdrawDoneRequest + ): WithdrawActionResult { + return webClient.post() + .uri("$baseUrl/admin/withdraw/${withdrawUuid}/done") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun rejectWithdraw( + token: String, + withdrawUuid: String, + request: WithdrawRejectRequest + ): WithdrawActionResult { + return webClient.post() + .uri("$baseUrl/admin/withdraw/${withdrawUuid}/reject") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun withdrawManually( + token: String, + symbol: String, + sourceUuid: String, + amount: BigDecimal, + request: ManualTransferRequest + ): TransferResult { + return webClient.post() + .uri("$baseUrl/admin/withdraw/manually/${amount}_${symbol}/${sourceUuid}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun depositManually( + token: String, + symbol: String, + receiverUuid: String, + amount: BigDecimal, + request: ManualTransferRequest + ): TransferResult { + return webClient.post() + .uri("$baseUrl/admin/deposit/manually/${amount}_${symbol}/${receiverUuid}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getDailyBalanceLast31Days( + token: String, + uuid: String + ): List { + + logger.info("fetching daily balance stats for {}", uuid) + + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/stats/balance/$uuid") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .bodyToMono>() + .awaitSingle() + } + } + + override suspend fun reserveSwap( + token: String, + request: TransferReserveRequest + ): ReservedTransferResponse { + return webClient.post() + .uri("$baseUrl/v3/transfer/reserve") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun finalizeSwap( + token: String, + reserveUuid: String, + description: String?, + transferRef: String? + ): TransferResult { + return webClient.post() + .uri("$baseUrl/v3/transfer/${reserveUuid}") { + it.queryParam("description", description) + it.queryParam("transferRef", transferRef) + it.build() + } + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getGatewayTerminal(gatewayUuid: String): List { + return webClient.get() + .uri("$baseUrl/currency/gateway/${gatewayUuid}/terminal") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.TerminalNotFound.exception() } + + } + + override suspend fun getUsersDetailAssets( + limit: Int, + offset: Int + ): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/stats/detail-assets") { + it.queryParam("limit", limit) + it.queryParam("offset", offset) + it.build() + }.accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { emptyList() } + } + } + + override suspend fun getCurrencyLocalizations( + token: String, + currency: String + ): CurrencyLocalizationResponse { + return webClient.get() + .uri("$baseUrl/currency/${currency}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun saveCurrencyLocalizations( + token: String, + currency: String, + currencyLocalizations: List + ): CurrencyLocalizationResponse { + return webClient.post() + .uri("$baseUrl/currency/${currency}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(currencyLocalizations)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun deleteCurrencyLocalization(token: String, id: Long) { + webClient.delete() + .uri("$baseUrl/currency/localization/${id}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getTerminalLocalizations( + token: String, + terminalUuid: String + ): TerminalLocalizationResponse { + return webClient.get() + .uri("$baseUrl/admin/terminal/${terminalUuid}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun saveTerminalLocalizations( + token: String, + terminalUuid: String, + terminalLocalizations: List + ): TerminalLocalizationResponse { + return webClient.post() + .uri("$baseUrl/admin/terminal/${terminalUuid}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(terminalLocalizations)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun deleteTerminalLocalization(token: String, id: Long) { + webClient.delete() + .uri("$baseUrl/admin/terminal/localization/${id}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getOffChainGatewayLocalizations( + token: String, + gatewayUuid: String + ): GatewayLocalizationResponse { + return webClient.get() + .uri("$baseUrl/offchain-gateway/${gatewayUuid}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun saveOffChainGatewayLocalizations( + token: String, + gatewayUuid: String, + gatewayLocalizations: List + ): GatewayLocalizationResponse { + return webClient.post() + .uri("$baseUrl/offchain-gateway/${gatewayUuid}/localization") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(gatewayLocalizations)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun deleteOffChainGatewayLocalization(token: String, id: Long) { + webClient.delete() + .uri("$baseUrl/offchain-gateway/localization/${id}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun saveTerminal( + token: String, + terminal: TerminalCommand + ): TerminalCommand? { + return webClient.post() + .uri("$baseUrl/admin/terminal") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(terminal)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun updateTerminal( + token: String, + terminalUuid: String, + terminal: TerminalUpdateCommand + ): TerminalCommand? { + return webClient.put() + .uri("$baseUrl/admin/terminal/${terminalUuid}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(terminal)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun deleteTerminal(token: String, terminalUuid: String) { + webClient.delete() + .uri("$baseUrl/admin/terminal/${terminalUuid}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getTerminals(token: String): List? { + return webClient.get() + .uri("$baseUrl/admin/deposit/terminal") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getTerminal( + token: String, + terminalUuid: String + ): TerminalCommand? { + return webClient.get() + .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.NotFound.exception() } + } + + override suspend fun getAssignedGatewayToTerminal( + token: String, + terminalUuid: String + ): List? { + return webClient.get() + .uri("$baseUrl/admin/terminal/${terminalUuid}/gateway") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToFlux() + .collectList() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun assignTerminalsToGateway( + token: String, + gatewayUuid: String, + terminal: List + ) { + webClient.post() + .uri("$baseUrl/currency/gateway/${gatewayUuid}/terminal") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(terminal)) + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun revokeTerminalsToGateway( + token: String, + gatewayUuid: String, + terminal: List + ) { + webClient.method(HttpMethod.DELETE) + .uri("$baseUrl/currency/gateway/${gatewayUuid}/terminal") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .bodyValue(terminal) + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun addGatewayToCurrency( + token: String, + currencySymbol: String, + gatewayCommand: CurrencyGatewayCommand + ): CurrencyGatewayCommand? { + return webClient.post() + .uri("$baseUrl/currency/${currencySymbol}/gateway") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(gatewayCommand)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun updateGateway( + token: String, + gatewayUuid: String, + currencySymbol: String, + gatewayCommand: CurrencyGatewayUpdateCommand + ): CurrencyGatewayCommand? { + return webClient.put() + .uri("$baseUrl/currency/${currencySymbol}/gateway/${gatewayUuid}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(gatewayCommand)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun getGateway( + token: String, + gatewayUuid: String, + currencySymbol: String + ): CurrencyGatewayCommand? { + return webClient.get() + .uri("$baseUrl/currency/${currencySymbol}/gateway/${gatewayUuid}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun deleteGateway( + token: String, + gatewayUuid: String, + currencySymbol: String + ) { + webClient.delete() + .uri("$baseUrl/currency/${currencySymbol}/gateway/${gatewayUuid}") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun submitDepositWebhook( + request: DepositWebhookRequest, + signature: String + ): DepositWebhookResponse { + logger.info("proxying deposit webhook to wallet") + + return withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/v1/deposit/webhook") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header(DepositWebhookHeaders.SIGNATURE, signature) + .bodyValue(request) + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun reserveSwapByAdmin( + token: String, + request: AdminTransferReserveRequest + ): ReservedTransferResponse { + return webClient.post() + .uri("$baseUrl/admin/v1/transfer/reserve") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun finalizeSwapByAdmin( + token: String, + reserveUuid: String, + description: String?, + transferRef: String? + ): TransferResult { + return webClient.post() + .uri("$baseUrl/admin/v1/transfer/${reserveUuid}") { + it.queryParam("description", description) + it.queryParam("transferRef", transferRef) + it.build() + } + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } +} -} \ No newline at end of file diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/utils/Extensions.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/utils/Extensions.kt new file mode 100644 index 000000000..55cc1692c --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/utils/Extensions.kt @@ -0,0 +1,45 @@ +package co.nilin.opex.api.ports.proxy.utils + +import org.springframework.http.* + +//fun tryRest(action: () -> T): T { +// return try { +// action() +// } +// catch (e: HttpClientErrorException) { +// logger.error("Client error fetching pair configs from $url: ${e.statusCode} - ${e.responseBodyAsString}") +// throw e +// } catch (e: HttpServerErrorException) { +// logger.error("Server error fetching pair configs from $url: ${e.statusCode} - ${e.responseBodyAsString}") +// throw e +// } catch (e: ResourceAccessException) { +// logger.error("Network or connection error fetching pair configs from $url: ${e.message}") +// throw e +// } catch (e: Exception) { +// logger.error("Unexpected error fetching pair configs from $url: ${e.message}", e) +// throw e +// } +//} + +internal fun defaultHeaders() = HttpHeaders().apply { + add(HttpHeaders.ACCEPT, "application/json") + add(HttpHeaders.CONTENT_TYPE, "application/json") +} + +internal fun defaultHeaders(contentType: MediaType) = HttpHeaders().apply { + add(HttpHeaders.ACCEPT, "application/json") + this.contentType = contentType +} + +internal fun body(body: Any) = HttpEntity(body, defaultHeaders()) + +internal fun body(body: Any, auth: String) = HttpEntity(body, defaultHeaders().withAuth(auth)) + +internal fun noBody() = HttpEntity(defaultHeaders()) + +internal fun noBody(auth: String?) = HttpEntity(defaultHeaders().withAuth(auth)) + +internal fun HttpHeaders.withAuth(auth: String?): HttpHeaders { + add("Authorization", "Bearer $auth") + return this +} \ No newline at end of file diff --git a/api/pom.xml b/api/pom.xml index 509278fa0..9c7a75971 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -22,7 +22,6 @@ 2.1.0 3.4.2 2024.0.0 - 1.1.0 @@ -43,6 +42,16 @@ co.nilin.opex common + + org.zalando + logbook-spring-boot-starter + 3.9.0 + + + org.zalando + logbook-spring-boot-autoconfigure + 3.9.0 + org.zalando logbook-spring-boot-webflux-autoconfigure @@ -82,11 +91,6 @@ interceptors ${interceptor.version} - - co.nilin.opex.utility - preferences - ${preferences.version} - org.springframework.cloud spring-cloud-dependencies diff --git a/auth-gateway/auth-gateway-app/Dockerfile b/auth-gateway/auth-gateway-app/Dockerfile new file mode 100644 index 000000000..83d2326a9 --- /dev/null +++ b/auth-gateway/auth-gateway-app/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jdk +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] +HEALTHCHECK --interval=45s --timeout=30s --start-period=60s --retries=5 CMD curl -sf 'http://localhost:8080/actuator/health' >/dev/null || exit 1 \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/pom.xml b/auth-gateway/auth-gateway-app/pom.xml new file mode 100644 index 000000000..1be88098a --- /dev/null +++ b/auth-gateway/auth-gateway-app/pom.xml @@ -0,0 +1,148 @@ + + + 4.0.0 + + + co.nilin.opex.auth + auth-gateway + 1.0.1-beta.7 + + + co.nilin.opex + auth-gateway-app + user-management-app + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.springframework.boot + spring-boot-starter-webflux + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.kafka + spring-kafka + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + 1.7.3 + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + org.springframework.cloud + spring-cloud-starter-consul-all + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.bouncycastle + bcprov-jdk15on + 1.60 + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + co.nilin.opex.utility + error-handler + + + co.nilin.opex.utility + interceptors + + + com.auth0 + java-jwt + 4.4.0 + + + com.google.api-client + google-api-client + 2.2.0 + + + com.auth0 + jwks-rsa + 0.22.1 + + + org.keycloak + keycloak-admin-client + 26.0.5 + + + com.sun.mail + jakarta.mail + 2.0.1 + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + + + io.micrometer + micrometer-registry-prometheus + runtime + + + org.springdoc + springdoc-openapi-starter-webflux-api + 2.6.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/AuthGateway.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/AuthGateway.kt new file mode 100644 index 000000000..e5c8d7dab --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/AuthGateway.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.auth + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan + +@SpringBootApplication +@ComponentScan("co.nilin.opex") +class AuthGateway + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/AppConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/AppConfig.kt new file mode 100644 index 000000000..ad135ecd6 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/AppConfig.kt @@ -0,0 +1,85 @@ +package co.nilin.opex.auth.config + +import co.nilin.opex.auth.kafka.KycLevelUpdatedKafkaListener +import co.nilin.opex.auth.kafka.ProfileUpdatedKafkaListener +import co.nilin.opex.auth.spi.KycLevelUpdatedEventListener +import co.nilin.opex.auth.spi.ProfileUpdatedEventListener +import jakarta.annotation.PostConstruct +import org.bouncycastle.util.io.pem.PemObject +import org.bouncycastle.util.io.pem.PemWriter +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStreamWriter +import java.io.StringWriter +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.PublicKey +import java.security.interfaces.RSAPrivateCrtKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec +import java.util.* + +@Configuration +class AppConfig { + + @PostConstruct + fun init() { + val pemFile = File("/app/keys/private.pem") + if (pemFile.exists()) + return + + val keypair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val privateKeyPem = convertPrivateKeyToPem(keypair.private) + + File("/app/keys").apply { if (!exists()) mkdir() } + OutputStreamWriter(FileOutputStream("/app/keys/private.pem")).use { it.write(privateKeyPem) } + } + + @Bean("privateKeyString") + fun privateKeyString(): String { + return File("/app/keys/private.pem").readText() + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\n", "") + } + + @Bean("privateKey") + fun privateKey(): PrivateKey { + val pKeyString = privateKeyString() + val keyBytes = Base64.getDecoder().decode(pKeyString) + val keySpec = PKCS8EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePrivate(keySpec) + } + + @Bean("publicKey") + fun publicKey(): PublicKey { + val privateKey = privateKey() as RSAPrivateCrtKey + val publicKeySpec = RSAPublicKeySpec(privateKey.modulus, privateKey.publicExponent) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePublic(publicKeySpec) + } + + private fun convertPrivateKeyToPem(privateKey: PrivateKey): String { + val keySpec = PKCS8EncodedKeySpec(privateKey.encoded) + val pemObject = PemObject("PRIVATE KEY", keySpec.encoded) + val stringWriter = StringWriter() + PemWriter(stringWriter).use { it.writeObject(pemObject) } + return stringWriter.toString() + } + + @Autowired + fun configureEventListeners( + kycLevelUpdatedKafkaListener: KycLevelUpdatedKafkaListener, + kycLevelUpdatedEventListener: KycLevelUpdatedEventListener, + profileUpdatedKafkaListener: ProfileUpdatedKafkaListener, + profileUpdatedEventListener: ProfileUpdatedEventListener, + ) { + kycLevelUpdatedKafkaListener.addEventListener(kycLevelUpdatedEventListener) + profileUpdatedKafkaListener.addEventListener(profileUpdatedEventListener) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/CaptchaConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/CaptchaConfig.kt new file mode 100644 index 000000000..fabe18049 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/CaptchaConfig.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "app.captcha") +class CaptchaConfig { + lateinit var url: String +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/CorsConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/CorsConfig.kt new file mode 100644 index 000000000..15a7bec43 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/CorsConfig.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 CorsConfig( + @Value("\${app.cors.enabled:false}") + private val enabled: Boolean, + + @Value("\${app.cors.allowed-origins:http://localhost:8110}") + private val allowedOrigins: String +) { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + fun CorsWebFilter(): CorsWebFilter { + val config = CorsConfiguration().apply { + allowedOrigins = if (enabled) { + this@CorsConfig.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/DeviceManagementConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/DeviceManagementConfig.kt new file mode 100644 index 000000000..1cbb527e7 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/DeviceManagementConfig.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "app.device-management") +class DeviceManagementConfig { + lateinit var url: String +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/ErrorHandlerConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/ErrorHandlerConfig.kt new file mode 100644 index 000000000..0b59cb4ae --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/ErrorHandlerConfig.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.config + +import co.nilin.opex.utility.error.EnableOpexErrorHandler +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableOpexErrorHandler +class ErrorHandlerConfig { + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaListenerConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaListenerConfig.kt new file mode 100644 index 000000000..eb8d5ce15 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaListenerConfig.kt @@ -0,0 +1,115 @@ +package co.nilin.opex.auth.config + + +import co.nilin.opex.auth.data.KycLevelUpdatedEvent +import co.nilin.opex.auth.data.ProfileUpdatedEvent +import co.nilin.opex.auth.kafka.KycLevelUpdatedKafkaListener +import co.nilin.opex.auth.kafka.ProfileUpdatedKafkaListener +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.serialization.StringDeserializer +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +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.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.listener.* +import org.springframework.kafka.support.serializer.JsonDeserializer +import org.springframework.util.backoff.FixedBackOff +import java.util.regex.Pattern + +@Configuration +class KafkaListenerConfig { + private val logger = LoggerFactory.getLogger(KafkaListenerConfig::class.java) + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean("consumerConfigs") + fun consumerConfigs(): Map { + + return mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, + JsonDeserializer.TRUSTED_PACKAGES to "co.nilin.opex.*", + JsonDeserializer.TYPE_MAPPINGS to "kyc_level_updated_event:co.nilin.opex.auth.data.KycLevelUpdatedEvent,profile_updated_event:co.nilin.opex.auth.data.ProfileUpdatedEvent" + ) + } + + // ------------------ KYC Event Beans ------------------ + + @Bean("kycLevelKafkaTemplate") + fun kycKafkaTemplate(@Qualifier("consumerConfigs") consumerConfigs: Map): KafkaTemplate { + return KafkaTemplate(DefaultKafkaProducerFactory(consumerConfigs)) + } + + @Bean("kycConsumerFactory") + fun kycConsumerFactory(@Qualifier("consumerConfigs") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + + @Autowired + @Bean + fun kycLevelUpdatedListenerContainer( + listener: KycLevelUpdatedKafkaListener, + @Qualifier("kycConsumerFactory") consumerFactory: ConsumerFactory, + @Qualifier("kycLevelKafkaTemplate") template: KafkaTemplate + ): ConcurrentMessageListenerContainer { + val containerProps = ContainerProperties(Pattern.compile("kyc_level_updated")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("KycLevelUpdatedKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "kyc_level_updated.DLT") + container.start() + return container + } + + // ------------------ Profile Updated Event Beans ------------------ + + @Bean("profileUpdatedKafkaTemplate") + fun profileKafkaTemplate(@Qualifier("consumerConfigs") consumerConfigs: Map): KafkaTemplate { + return KafkaTemplate(DefaultKafkaProducerFactory(consumerConfigs)) + } + + @Bean("profileConsumerFactory") + fun profileConsumerFactory(@Qualifier("consumerConfigs") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + + @Autowired + @Bean + fun profileUpdatedListenerContainer( + listener: ProfileUpdatedKafkaListener, + @Qualifier("profileConsumerFactory") consumerFactory: ConsumerFactory, + @Qualifier("profileUpdatedKafkaTemplate") template: KafkaTemplate + ): ConcurrentMessageListenerContainer { + val containerProps = ContainerProperties(Pattern.compile("profile_updated")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("ProfileUpdatedKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "profile_updated.DLT") + container.start() + return container + } + + // ------------------ Shared Error Handler ------------------ + + private fun createConsumerErrorHandler(kafkaTemplate: KafkaTemplate<*, *>, dltTopic: String): CommonErrorHandler { + val recoverer = DeadLetterPublishingRecoverer(kafkaTemplate) { cr, _ -> + cr.headers().add("dlt-origin-module", "PROFILE".toByteArray()) + TopicPartition(dltTopic, cr.partition()) + } + return DefaultErrorHandler(recoverer, FixedBackOff(5_000, 20)) + } + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaProducerConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaProducerConfig.kt new file mode 100644 index 000000000..27e74fca6 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaProducerConfig.kt @@ -0,0 +1,53 @@ +package co.nilin.opex.auth.config + +import co.nilin.opex.auth.data.AuthEvent +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.support.GenericApplicationContext +import org.springframework.kafka.config.TopicBuilder +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonSerializer +import java.util.function.Supplier + +object KafkaTopics { + const val AUTH = "auth" + const val LOGIN = "login" + const val LOGOUT = "logout" + +} + +@Configuration +class KafkaProducerConfig( + @Value("\${spring.kafka.bootstrap-servers}") + private val bootstrapServers: String +) { + + @Bean + fun producerConfigs(): Map { + return mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, + ProducerConfig.ACKS_CONFIG to "all", + JsonSerializer.TYPE_MAPPINGS to "userCreatedEvent:co.nilin.opex.auth.data.UserCreatedEvent,loginEvent:co.nilin.opex.auth.data.LoginEvent,logoutEvent:co.nilin.opex.auth.data.LogoutEvent" + ) + } + + @Bean + fun producerFactory(producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean + fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaTopicConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaTopicConfig.kt new file mode 100644 index 000000000..87b06edc4 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KafkaTopicConfig.kt @@ -0,0 +1,58 @@ +package co.nilin.opex.auth.config + +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.common.config.TopicConfig +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import org.springframework.context.support.GenericApplicationContext +import org.springframework.kafka.config.TopicBuilder +import java.util.function.Supplier +@Configuration +class KafkaTopicConfig { + private val logger = LoggerFactory.getLogger(KafkaTopicConfig::class.java) + + @Value("\${spring.kafka.replica:3}") + private var replicaCount: Int = 3 + + @Value("\${spring.kafka.partitions:10}") + private var partitionCount: Int = 10 + + @Value("\${spring.kafka.min-sync-replica:2}") + private lateinit var minSyncReplicaCount: String + + @Autowired + fun createTopics(applicationContext: GenericApplicationContext) { + logger.info("Creating kafka topic beans...") + + with(applicationContext) { + registerBean("topic_auth", NewTopic::class.java, Supplier { + TopicBuilder.name(KafkaTopics.AUTH) + .partitions(partitionCount) + .replicas(replicaCount) + .config(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, minSyncReplicaCount) + .build() + }) + + registerBean("topic_login", NewTopic::class.java, Supplier { + TopicBuilder.name(KafkaTopics.LOGIN) + .partitions(partitionCount) + .replicas(replicaCount) + .config(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, minSyncReplicaCount) + .build() + }) + registerBean("topic_logout", NewTopic::class.java, Supplier { + TopicBuilder.name(KafkaTopics.LOGOUT) + .partitions(partitionCount) + .replicas(replicaCount) + .config(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, minSyncReplicaCount) + .build() + }) + + + logger.info("Kafka topics created") + } + + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakAdminConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakAdminConfig.kt new file mode 100644 index 000000000..4f87db169 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakAdminConfig.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.auth.config + +import org.keycloak.admin.client.Keycloak +import org.keycloak.admin.client.KeycloakBuilder +import org.keycloak.admin.client.resource.RealmResource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class KeycloakAdminConfig { + + @Bean + fun keycloak(config: KeycloakConfig): Keycloak { + return KeycloakBuilder.builder() + .serverUrl(config.url) + .realm(config.realm) + .clientId(config.adminClient.id) + .clientSecret(config.adminClient.secret) + .grantType("client_credentials") + .build() + } + + @Bean + fun opexRealm(keycloak: Keycloak, config: KeycloakConfig): RealmResource { + return keycloak.realm(config.realm) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakConfig.kt new file mode 100644 index 000000000..f947b4d4a --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakConfig.kt @@ -0,0 +1,20 @@ +package co.nilin.opex.auth.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "keycloak") +class KeycloakConfig { + lateinit var url: String + lateinit var certUrl: String + lateinit var issUrl: String + lateinit var realm: String + lateinit var adminClient: Client +} + +data class Client( + val id: String, + val secret: String, + val googleClientId: String? +) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OTPConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OTPConfig.kt new file mode 100644 index 000000000..1b94b8495 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OTPConfig.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "app.otp") +class OTPConfig { + lateinit var url: String +} \ No newline at end of file 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..6955a8c89 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/OpenApiConfig.kt @@ -0,0 +1,49 @@ +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( + @Value("\${app.openapi.server-url:}") + private val serverUrl: String +) { + + @Bean + fun authGatewayOpenApi(): OpenAPI { + val openApi = 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") + ) + ) + if (serverUrl.isNotBlank()) { + openApi.servers( + listOf( + Server() + .url(serverUrl) + .description("Public API server") + ) + ) + } + return openApi + } +} 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 new file mode 100644 index 000000000..3911ec4ff --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt @@ -0,0 +1,138 @@ +package co.nilin.opex.auth.config + +import co.nilin.opex.auth.utils.AudienceValidator +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.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 +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.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers +import org.springframework.web.reactive.function.client.WebClient + +@EnableWebFluxSecurity +@Configuration +class SecurityConfig( + @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(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() + } + + @Bean + @Order(1) + fun preAuthSecurityChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http + .securityMatcher( + ServerWebExchangeMatchers.pathMatchers( + "/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.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 + fun reactiveJwtDecoder(): ReactiveJwtDecoder? { + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(keycloakConfig.certUrl) + .webClient(webClient) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(keycloakConfig.issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder + } + + @Bean("preAuthJwtDecoder") + @Throws(Exception::class) + fun preAuthJwtDecoder(): ReactiveJwtDecoder? { + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(keycloakConfig.certUrl) + .webClient(webClient) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(keycloakConfig.issUrl) + val audienceValidator = AudienceValidator( + setOf( + "pre-auth-client", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder + } +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/WebClientConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/WebClientConfig.kt new file mode 100644 index 000000000..579e9234b --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/WebClientConfig.kt @@ -0,0 +1,79 @@ +package co.nilin.opex.auth.config + +import io.netty.channel.ChannelOption +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.cloud.client.loadbalancer.LoadBalanced +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import org.zalando.logbook.Logbook +import org.zalando.logbook.netty.LogbookClientHandler +import reactor.netty.http.client.HttpClient +import reactor.netty.resources.ConnectionProvider +import java.time.Duration + +@Configuration +class WebClientConfig { + + @Bean("keycloakWebClient") + fun keycloakWebClient(keycloakConfig: KeycloakConfig, logbook: Logbook): WebClient { + val provider = ConnectionProvider.builder("keycloakPool") + .maxConnections(100) + .maxIdleTime(Duration.ofSeconds(30)) + .maxLifeTime(Duration.ofMinutes(2)) + .pendingAcquireTimeout(Duration.ofSeconds(60)) + .evictInBackground(Duration.ofMinutes(1)) + .build() + + val client = HttpClient.create(provider) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) + .responseTimeout(Duration.ofSeconds(10)) + .keepAlive(true) + .doOnConnected { it.addHandlerLast(LogbookClientHandler(logbook)) } + + client.warmup().block() + + return WebClient.builder() + .clientConnector(ReactorClientHttpConnector(client)) + .baseUrl(keycloakConfig.url) + .build() + } + + @LoadBalanced + @Bean("otpWebclientBuilder") + fun otpWebClientBuilder(otpConfig: OTPConfig): WebClient.Builder { + return WebClient.builder().baseUrl(otpConfig.url) + } + + @Bean("otpWebClient") + fun otpWebClient(@Qualifier("otpWebclientBuilder") builder: WebClient.Builder): WebClient { + return builder.build() + } + + @LoadBalanced + @Bean("captchaWebclientBuilder") + fun captchaWebClientBuilder(captchaConfig: CaptchaConfig): WebClient.Builder { + return WebClient.builder().baseUrl(captchaConfig.url) + } + + + @Bean("captchaWebClient") + fun captchaWebClient(@Qualifier("captchaWebclientBuilder") builder: WebClient.Builder): WebClient { + return builder.build() + } + + @LoadBalanced + @Bean("deviceManagementWebclientBuilder") + fun deviceManagementWebClientBuilder(deviceManagement: DeviceManagementConfig): WebClient.Builder { + return WebClient.builder().baseUrl(deviceManagement.url) + } + + + @Bean("deviceManagementClient") + fun deviceManagementWebClient(@Qualifier("deviceManagementWebclientBuilder") builder: WebClient.Builder): WebClient { + return builder.build() + } + + +} \ No newline at end of file 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 new file mode 100644 index 000000000..5270d40d4 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt @@ -0,0 +1,144 @@ +package co.nilin.opex.auth.controller + +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 +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("/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) + return ResponseEntity.ok().body(response) + } + + @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 new file mode 100644 index 000000000..7b48faeaa --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt @@ -0,0 +1,187 @@ +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 +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +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 +) { + + + @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() + } + + @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() + } +} 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 new file mode 100644 index 000000000..b2235ace6 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt @@ -0,0 +1,206 @@ +package co.nilin.opex.auth.controller + +import co.nilin.opex.auth.data.SessionRequest +import co.nilin.opex.auth.data.Sessions +import co.nilin.opex.auth.service.ForgetPasswordService +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, + private val sessionService: SessionService +) { + + @PostMapping("/logout") + @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() + logoutService.logout(userId, sid) + } + + @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 { + val uuid = securityContext.authentication.name + val sid = securityContext.jwtAuthentication().tokenAttributes["sid"] as String? + ?: throw OpexError.InvalidToken.exception() + sessionRequest.uuid = uuid + return sessionService.fetchSessions(sessionRequest, sid) + } + + @DeleteMapping("/session/{sessionId}") + @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") + @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() + logoutService.logoutOthers(uuid, sid) + } + + @PostMapping("/session/delete-all") + @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/data/AuthEvent.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/AuthEvent.kt new file mode 100644 index 000000000..3fc9f8dca --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/AuthEvent.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.auth.data + +import java.time.LocalDateTime + +open class AuthEvent { + + val time: LocalDateTime = LocalDateTime.now() +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/Device.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/Device.kt new file mode 100644 index 000000000..a588d5030 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/Device.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.auth.data + +open class Device { + var deviceUuid: String?=null + var appVersion: String?=null + var osVersion: String?=null + var os: Os?=null +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/KycLevelUpdatedEvent.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/KycLevelUpdatedEvent.kt new file mode 100644 index 000000000..eaf7d3e3f --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/KycLevelUpdatedEvent.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.auth.data + +import java.time.LocalDateTime + +data class KycLevelUpdatedEvent(var userId: String, var kycLevel: KycLevel, var updateDate: LocalDateTime) + +enum class KycLevel { + LEVEL_1, LEVEL_2, LEVEL_3 +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/LoginEvent.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/LoginEvent.kt new file mode 100644 index 000000000..5949762d5 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/LoginEvent.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.auth.data + +import java.time.LocalDateTime + +data class LoginEvent( + val uuid: String, + val sessionId: String?, + val deviceUuid: String?, + val appVersion: String?, + val osVersion: String?, + val expireDate: LocalDateTime, + val os: Os?, +) : AuthEvent() \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/LogoutEvent.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/LogoutEvent.kt new file mode 100644 index 000000000..6d0dc8a51 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/LogoutEvent.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.auth.data + +data class LogoutEvent( + val uuid: String, + val sessionId: String?, + val logoutOthers: Boolean? = false +) : AuthEvent() \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/ProfileUpdatedEvent.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/ProfileUpdatedEvent.kt new file mode 100644 index 000000000..2f053b51c --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/ProfileUpdatedEvent.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.auth.data + +data class ProfileUpdatedEvent( + var userId: String, + var firstName: String? = null, + var lastName: String? = null, + var email: String? = null, + var mobile: String? = null +) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/Session.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/Session.kt new file mode 100644 index 000000000..bf37da982 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/Session.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.auth.data + +import java.time.LocalDateTime + +data class Sessions( + val deviceUuid: String?, + val os: Os?, + val osVersion: String?, + val appVersion: String?, + val firstLoginDate: LocalDateTime?, + val lastLoginDate: LocalDateTime?, + val sessionState: String?, + val sessionStatus: SessionStatus?, + val sessionCreateDate: LocalDateTime?, + val sessionExpireDate: LocalDateTime?, + var isCurrentSession: Boolean?=false +) + +enum class SessionStatus { + ACTIVE, + EXPIRED, + TERMINATED +} + +enum class Os { + ANDROID, IOS, MOBILE_WEB, DESKTOP_WEB +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/SessionRequest.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/SessionRequest.kt new file mode 100644 index 000000000..6b86e4351 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/SessionRequest.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.data + +data class SessionRequest( + var uuid: String? = null, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, + val os: Os? = null, + val status: SessionStatus? = null +) \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/UserCreatedEvent.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/UserCreatedEvent.kt new file mode 100644 index 000000000..964c6b7da --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/UserCreatedEvent.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.data + +data class UserCreatedEvent( + val uuid: String, + val username: String, + val email: String?, + val mobile: String?, + val firstName: String?, + val lastName: String? +) : AuthEvent() \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/UserRole.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/UserRole.kt new file mode 100644 index 000000000..aae3a844c --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/data/UserRole.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.auth.data + +enum class UserRole(val keycloakName: String) { + LEVEL_1("user-1"), + LEVEL_2("user-2"), + LEVEL_3("user-3"), +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/AuthEventProducer.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/AuthEventProducer.kt new file mode 100644 index 000000000..eeb464752 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/AuthEventProducer.kt @@ -0,0 +1,57 @@ +package co.nilin.opex.auth.kafka + +import co.nilin.opex.auth.config.KafkaTopics +import co.nilin.opex.auth.data.AuthEvent +import co.nilin.opex.auth.data.LoginEvent +import co.nilin.opex.auth.data.LogoutEvent +import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.retry.support.RetryTemplate +import org.springframework.stereotype.Component + +@Component +class AuthEventProducer(private val authTemplate: KafkaTemplate, + private val loginTemplate: KafkaTemplate) { + + private val logger by LoggerDelegate() + + private val retryTemplate = RetryTemplate.builder() + .maxAttempts(10) + .exponentialBackoff(1000, 1.8, 5 * 60 * 1000) + .retryOn(Exception::class.java) + .build() + + fun send(event: AuthEvent) { + retryTemplate.execute { + authTemplate.send(KafkaTopics.AUTH, event).whenComplete { res, error -> + if (error != null) { + logger.error("Error sending auth event", error) + throw error + } + logger.info("Auth event sent") + } + } + } + fun send(event: LoginEvent) { + retryTemplate.execute { + loginTemplate.send(KafkaTopics.LOGIN, event).whenComplete { res, error -> + if (error != null) { + logger.error("Error sending login event", error) + throw error + } + logger.info("login event sent") + } + } + } + fun send(event: LogoutEvent) { + retryTemplate.execute { + loginTemplate.send(KafkaTopics.LOGOUT, event).whenComplete { res, error -> + if (error != null) { + logger.error("Error sending logout event", error) + throw error + } + logger.info("logout event sent") + } + } + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/KycLevelUpdatedKafkaListener.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/KycLevelUpdatedKafkaListener.kt new file mode 100644 index 000000000..d98a3c19d --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/KycLevelUpdatedKafkaListener.kt @@ -0,0 +1,26 @@ +package co.nilin.opex.auth.kafka + + +import co.nilin.opex.auth.data.KycLevelUpdatedEvent +import co.nilin.opex.auth.spi.KycLevelUpdatedEventListener +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.listener.MessageListener +import org.springframework.stereotype.Component + +@Component +class KycLevelUpdatedKafkaListener : MessageListener { + val eventListeners = arrayListOf() + private val logger = LoggerFactory.getLogger(KycLevelUpdatedKafkaListener::class.java) + override fun onMessage(data: ConsumerRecord) { + + eventListeners.forEach { tl -> + logger.info("incoming new event " + tl.id()) + tl.onEvent(data.value(), data.partition(), data.offset(), data.timestamp(), tl.id()) + } + } + + fun addEventListener(tl: KycLevelUpdatedEventListener) { + eventListeners.add(tl) + } +} \ No newline at end of file diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/KycLevelUpdatedListener.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/KycLevelUpdatedListener.kt similarity index 54% rename from accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/KycLevelUpdatedListener.kt rename to auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/KycLevelUpdatedListener.kt index b7ff38ba7..e6e13c2ae 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/KycLevelUpdatedListener.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/KycLevelUpdatedListener.kt @@ -1,16 +1,17 @@ -package co.nilin.opex.accountant.app.listener +package co.nilin.opex.auth.kafka -import co.nilin.opex.accountant.core.inout.KycLevelUpdatedEvent -import co.nilin.opex.accountant.ports.kafka.listener.spi.KycLevelUpdatedEventListener -import co.nilin.opex.accountant.ports.postgres.impl.UserLevelLoaderImpl -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component +import co.nilin.opex.auth.data.KycLevelUpdatedEvent +import co.nilin.opex.auth.data.UserRole +import co.nilin.opex.auth.proxy.KeycloakProxy +import co.nilin.opex.auth.spi.KycLevelUpdatedEventListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component @Component -class KycLevelUpdatedListener(val userLevelLoaderImpl: UserLevelLoaderImpl) : KycLevelUpdatedEventListener { +class KycLevelUpdatedListener(private val keycloakProxy: KeycloakProxy) : KycLevelUpdatedEventListener { private val logger = LoggerFactory.getLogger(KycLevelUpdatedListener::class.java) val scope = CoroutineScope(Dispatchers.IO) @@ -18,15 +19,14 @@ class KycLevelUpdatedListener(val userLevelLoaderImpl: UserLevelLoaderImpl) : Ky return "KycLevelUpdatedListener" } - override fun onEvent(event: KycLevelUpdatedEvent, - partition: Int, offset: Long, timestamp: Long, eventId: String) { + override fun onEvent( + event: KycLevelUpdatedEvent, partition: Int, offset: Long, timestamp: Long, eventId: String + ) { logger.info("==========================================================================") logger.info("Incoming UserLevelUpdated event: $event") logger.info("==========================================================================") scope.launch { - userLevelLoaderImpl.update(event.userId, event.kycLevel) + keycloakProxy.assignRole(event.userId, UserRole.valueOf(event.kycLevel.name)) } } - - } \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/ProfileUpdatedKafkaListener.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/ProfileUpdatedKafkaListener.kt new file mode 100644 index 000000000..61aa811d7 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/ProfileUpdatedKafkaListener.kt @@ -0,0 +1,26 @@ +package co.nilin.opex.auth.kafka + + +import co.nilin.opex.auth.data.ProfileUpdatedEvent +import co.nilin.opex.auth.spi.ProfileUpdatedEventListener +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.listener.MessageListener +import org.springframework.stereotype.Component + +@Component +class ProfileUpdatedKafkaListener : MessageListener { + val eventListeners = arrayListOf() + private val logger = LoggerFactory.getLogger(ProfileUpdatedKafkaListener::class.java) + override fun onMessage(data: ConsumerRecord) { + + eventListeners.forEach { tl -> + logger.info("incoming new event " + tl.id()) + tl.onEvent(data.value(), data.partition(), data.offset(), data.timestamp(), tl.id()) + } + } + + fun addEventListener(tl: ProfileUpdatedEventListener) { + eventListeners.add(tl) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/ProfileUpdatedListener.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/ProfileUpdatedListener.kt new file mode 100644 index 000000000..e7700debf --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/kafka/ProfileUpdatedListener.kt @@ -0,0 +1,31 @@ +package co.nilin.opex.auth.kafka + +import co.nilin.opex.auth.data.ProfileUpdatedEvent +import co.nilin.opex.auth.proxy.KeycloakProxy +import co.nilin.opex.auth.spi.ProfileUpdatedEventListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class ProfileUpdatedListener(private val keycloakProxy: KeycloakProxy) : ProfileUpdatedEventListener { + + private val logger = LoggerFactory.getLogger(ProfileUpdatedListener::class.java) + val scope = CoroutineScope(Dispatchers.IO) + override fun id(): String { + return "ProfileUpdatedListener" + } + + override fun onEvent( + event: ProfileUpdatedEvent, partition: Int, offset: Long, timestamp: Long, eventId: String + ) { + logger.info("==========================================================================") + logger.info("Incoming ProfileUpdatedEvent event: $event") + logger.info("==========================================================================") + scope.launch { + keycloakProxy.updateUserInfo(event.userId, event.mobile, event.email, event.firstName, event.lastName) + } + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Attribute.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Attribute.kt new file mode 100644 index 000000000..32f2b9076 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Attribute.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.auth.model + +data class Attribute( + val key: String, + val value: String +) + +object Attributes { + + const val EMAIL = "email" + const val MOBILE = "mobile" + const val OTP = "otpConfig" +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Captcha.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Captcha.kt new file mode 100644 index 000000000..9e564c6ec --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Captcha.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.auth.model + +enum class CaptchaType { + INTERNAL, ARCAPTCHA, HCAPTCHA +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Error.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Error.kt new file mode 100644 index 000000000..e78d63016 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Error.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.auth.model + +import java.time.Instant + +data class ErrorResponse( + val timestamp: Instant, // Timestamp of the error + val status: Int, // HTTP status code + val error: String, // HTTP status reason phrase (e.g., "Bad Request") + val message: String, // Error message + val path: String // API path where the error occurred +) \ No newline at end of file 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 new file mode 100644 index 000000000..3cf756c22 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt @@ -0,0 +1,36 @@ +package co.nilin.opex.auth.model + +import jakarta.validation.constraints.NotBlank + +data class OTPReceiver( + val receiver: String, + val type: OTPType, +) + +data class OTPCode( + @field:NotBlank(message = "code is required") + val code: String, + + @field:NotBlank(message = "otpType is required") + val otpType: OTPType, +) + +data class OTPVerifyRequest( + val userId: String, + val otpCodes: List +) + +data class OTPVerifyResponse( + val result: Boolean, + val type: OTPResultType +) + +data class TempOtpResponse(val otp: String?, val otpReceiver: OTPReceiver?) + +enum class OTPAction { + REGISTER, FORGET, NONE +} + +enum class OTPResultType { + VALID, EXPIRED, INCORRECT, INVALID +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt new file mode 100644 index 000000000..a381652f2 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt @@ -0,0 +1,87 @@ +package co.nilin.opex.auth.model + +import co.nilin.opex.auth.data.Device +import com.fasterxml.jackson.annotation.JsonProperty + +data class PasswordFlowTokenRequest( + val username: String, + val password: String, + val clientId: String, + val clientSecret: String?, + val rememberMe: Boolean = true, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +):Device() + +data class ConfirmPasswordFlowTokenRequest( + val username: String, + val token: String, + val clientId: String, + val clientSecret: String?, + val otp: String, + val rememberMe: Boolean = true, +): Device() + +data class ResendOtpRequest( + val username: String, + val clientId: String +) + + +data class RefreshTokenRequest( + val clientId: String, + val clientSecret: String?, + val refreshToken: String +):Device() + +data class ExternalIdpTokenRequest( + val idToken: String, + val accessToken: String, + val idp: String, + val otpVerifyRequest: OTPVerifyRequest? +):Device() + +data class Token( + @JsonProperty("access_token") + val accessToken: String, // The access token + + @JsonProperty("expires_in") + val expiresIn: Int, // Expiration time of the access token in seconds + + @JsonProperty("refresh_expires_in") + var refreshExpiresIn: Int?, // Expiration time of the refresh token in seconds + + @JsonProperty("refresh_token") + var refreshToken: String?, // The refresh token + + @JsonProperty("token_type") + val tokenType: String?, // Type of token (usually "Bearer") + + @JsonProperty("not-before-policy") + val notBeforePolicy: Int?, // Timestamp indicating when the token becomes valid + + @JsonProperty("session_state") + val sessionState: String?, // Session state (optional) + + @JsonProperty("scope") + val scope: String? // Scopes associated with the token + +) + +data class TokenResponse( + val token: Token?, + val otp: RequiredOTP?, + //TODO IMPORTANT: remove in production + val otpCode: String?, +) + +data class RequiredOTP( + val type: OTPType, + val receiver: String? +) + +data class ResendOtpResponse( + val otp: RequiredOTP?, + //TODO IMPORTANT: remove in production + val otpCode: String?, +) \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserRegister.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserRegister.kt new file mode 100644 index 000000000..88cdb430a --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserRegister.kt @@ -0,0 +1,65 @@ +package co.nilin.opex.auth.model + +import co.nilin.opex.auth.data.Device + +data class RegisterUserRequest( + val username: String, + val firstName: String? = null, + val lastName: String? = null, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +) + +data class VerifyOTPRequest( + val username: String, + val otp: String, +) + +data class OTPActionTokenResponse( + val token: String, +) + +data class ConfirmRegisterRequest( + val password: String, + val token: String, + val clientId: String?, + val clientSecret: String?, +) : Device() + +data class TokenData( + val isValid: Boolean, + val userId: String, + val action: OTPAction, +) + +data class ExternalIdpUserRegisterRequest( + val idToken: String, + val idp: String, + val password: String, + val otpVerifyRequest: OTPVerifyRequest?, +) + +data class KeycloakUser( + val id: String, + val username: String, + val email: String?, + val firstName: String?, + val lastName: String?, + val emailVerified: Boolean, + val enabled: Boolean, + val attributes: Map>?, +) { + val mobile: String? = attributes?.get(Attributes.MOBILE)?.get(0) +} + +data class ConfirmForgetRequest( + val newPassword: String, + val newPasswordConfirmation: String, + val token: String, +) + +data class ForgotPasswordRequest( + val username: String, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +) \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserUpdate.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserUpdate.kt new file mode 100644 index 000000000..558eb55ce --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserUpdate.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.auth.model + +data class UpdateMobileRequest( + val userId :String, + val mobile: String +) + +data class UpdateEmailRequest( + val userId :String, + val email: String +) + +data class UpdateNameRequest( + val userId :String, + val firstName: String, + val lastName: String +) \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Username.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Username.kt new file mode 100644 index 000000000..6589cd493 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Username.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.auth.model + +import co.nilin.opex.auth.utils.UsernameValidator +import co.nilin.opex.common.OpexError + +data class Username( + val value: String, + val type: UsernameType +) { + + fun asAttribute() = Attribute(type.name.lowercase(), value) + + companion object { + fun create(username: String): Username { + val type = UsernameValidator.getType(username.replace("+", "")) + if (type.isUnknown()) throw OpexError.InvalidUsername.exception() + return Username(username, type) + } + } +} + +enum class UsernameType(val otpType: OTPType) { + MOBILE(OTPType.SMS), + EMAIL(OTPType.EMAIL), + UNKNOWN(OTPType.NONE); + + fun isUnknown() = this == UNKNOWN +} + +enum class OTPType { + EMAIL, SMS, TOTP, NONE +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/CaptchaProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/CaptchaProxy.kt new file mode 100644 index 000000000..03e7f3f3a --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/CaptchaProxy.kt @@ -0,0 +1,37 @@ +package co.nilin.opex.auth.proxy + +import co.nilin.opex.auth.model.CaptchaType +import co.nilin.opex.common.OpexError +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + +@Component +class CaptchaProxy( + @Value("\${app.captcha.enabled}") private val captchaEnabled: Boolean, + @Qualifier("captchaWebClient") private val webClient: WebClient, +) { + + suspend fun validateCaptcha(proof: String, type: CaptchaType) { + if (captchaEnabled) { + val statusCode = webClient.get().uri("/verify") { + it.queryParam("type", type) + it.queryParam("proof", proof) + it.build() + }.accept(MediaType.APPLICATION_JSON).header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .exchangeToMono { response -> Mono.just(response.statusCode()) }.awaitFirstOrNull() + + when (statusCode) { + HttpStatus.NO_CONTENT -> return + HttpStatus.BAD_REQUEST -> throw OpexError.InvalidCaptcha.exception() + else -> throw OpexError.BadRequest.exception("Error in verify captcha") + } + } + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/DeviceManagementProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/DeviceManagementProxy.kt new file mode 100644 index 000000000..3b0bd99fd --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/DeviceManagementProxy.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.auth.proxy + +import co.nilin.opex.auth.config.DeviceManagementConfig +import co.nilin.opex.auth.data.Sessions +import co.nilin.opex.auth.data.SessionRequest +import co.nilin.opex.common.OpexError +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody + +@Component +class DeviceManagementProxy( + @Qualifier("deviceManagementClient") + private val client: WebClient, + private val deviceManagementConfig: DeviceManagementConfig +) { + suspend fun getLastSessions( + sessionRequest: SessionRequest, + ): List { + val url = "${deviceManagementConfig.url}/devices" + + return client.post() + .uri(url) + .bodyValue(sessionRequest) + .retrieve() + .onStatus({ it.is4xxClientError }) { + throw OpexError.BadRequest.exception() + } + .onStatus({ it.is5xxServerError }) { + throw OpexError.InternalServerError.exception() + } + .awaitBody() + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/GoogleIdpProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/GoogleIdpProxy.kt new file mode 100644 index 000000000..72ef7457c --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/GoogleIdpProxy.kt @@ -0,0 +1,34 @@ +package co.nilin.opex.auth.proxy + +import co.nilin.opex.auth.config.KeycloakConfig +import com.auth0.jwk.JwkProvider +import com.auth0.jwk.JwkProviderBuilder +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.JWTVerificationException +import com.auth0.jwt.interfaces.DecodedJWT +import org.springframework.stereotype.Service +import java.net.URL +import java.security.interfaces.RSAPublicKey + +@Service +class GoogleProxy(private val keycloakConfig: KeycloakConfig) { + + fun validateGoogleToken(googleToken: String): DecodedJWT { + // Step 1: Fetch Google's public keys + val jwkProvider: JwkProvider = JwkProviderBuilder(URL("https://www.googleapis.com/oauth2/v3/certs")) + .build() + + // Step 2: Decode and verify the token + val algorithm = Algorithm.RSA256(jwkProvider.get(JWT.decode(googleToken).keyId).publicKey as RSAPublicKey, null) + val verifier = JWT.require(algorithm) + .withIssuer("https://accounts.google.com") + .build() + + val decoded = verifier.verify(googleToken) + if ( decoded.audience.isEmpty() || !decoded.audience.contains(keycloakConfig.adminClient.googleClientId)){ + throw JWTVerificationException("Google token's audience doesn't match") + } + return decoded + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt new file mode 100644 index 000000000..5fe5e21b7 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt @@ -0,0 +1,471 @@ +package co.nilin.opex.auth.proxy + +import co.nilin.opex.auth.config.KeycloakConfig +import co.nilin.opex.auth.data.UserRole +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.utils.generateRandomID +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.keycloak.admin.client.resource.RealmResource +import org.keycloak.admin.client.resource.UserResource +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBodilessEntity +import org.springframework.web.reactive.function.client.awaitBody +import org.springframework.web.reactive.function.client.bodyToMono + +@Service +class KeycloakProxy( + @Qualifier("keycloakWebClient") + private val keycloakClient: WebClient, + private val keycloakConfig: KeycloakConfig, + private val opexRealm: RealmResource +) { + + private val adminClient = keycloakConfig.adminClient + private val logger by LoggerDelegate() + + suspend fun getAdminAccessToken(): String { + val tokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + val response = keycloakClient.post() + .uri(tokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue("client_id=${adminClient.id}&client_secret=${adminClient.secret}&grant_type=client_credentials") + .retrieve() + .awaitBody() // Assuming the response is a JSON object + return response.accessToken + } + + suspend fun getUserToken( + username: Username, + password: String?, + clientId: String, + clientSecret: String? + ): Token { + val users = findUserByAttribute(username.asAttribute()) + if (users.isEmpty()) + throw OpexError.UserNotFound.exception() + + val userTokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + return keycloakClient.post() + .uri(userTokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue("client_id=${clientId}&client_secret=${clientSecret}&grant_type=password&username=${users[0].username}&password=${password}") + .retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { + throw OpexError.UsernameOrPasswordIsIncorrect.exception() + } + .awaitBody() + } + + suspend fun exchangeUserToken( + token: String, + clientId: String, + clientSecret: String?, + targetClientId: String + ): Token { + val userTokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + + return keycloakClient.post() + .uri(userTokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue( + "client_id=${clientId}" + + "&client_secret=${clientSecret}" + + "&grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&subject_token=${token}" + + "&audience=${targetClientId}" + + "&requested_token_type=urn:ietf:params:oauth:token-type:refresh_token" + ) + .retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { + throw OpexError.UsernameOrPasswordIsIncorrect.exception() + } + .awaitBody() + } + + + suspend fun checkUserCredentials(user: KeycloakUser, password: String) { + keycloakClient.post() + .uri("${keycloakConfig.url}/realms/${keycloakConfig.realm}/password/validate") + .header("Content-Type", "application/json") + .bodyValue( + object { + val userId = user.id + val password = password + } + ).retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { throw OpexError.InvalidUserCredentials.exception() } + .awaitBodilessEntity() + } + + suspend fun refreshUserToken( + refreshToken: String, + clientId: String, + clientSecret: String? + ): Token { + val userTokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + return keycloakClient.post() + .uri(userTokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue("client_id=${clientId}&client_secret=${clientSecret}&grant_type=refresh_token&refresh_token=${refreshToken}") + .retrieve() + .awaitBody() + } + + suspend fun exchangeGoogleTokenForKeycloakToken(accessToken: String): Token { + val tokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + val requestBody = + "client_id=${adminClient.id}&client_secret=${adminClient.secret}&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=$accessToken&subject_token_type=urn:ietf:params:oauth:token-type:access_token&subject_issuer=google" + return keycloakClient.post() + .uri(tokenUrl) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .bodyValue(requestBody) + .retrieve() + .bodyToMono() + .awaitSingle() + } + + suspend fun findUserByEmail(email: String): String { + // Step 1: Build the URL for the Keycloak Admin REST API + val userSearchUrl = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users?email=${email}" + + // Step 2: Make a GET request to Keycloak's Admin REST API + val users = keycloakClient.get() + .uri(userSearchUrl) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${getAdminAccessToken()}") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono>() + .awaitSingle() + + // Step 3: Check if a user was found + if (users.isEmpty()) { + throw IllegalArgumentException("No user found with email: $email") + } + + // Step 4: Return the username of the first user in the list + return users[0].id + } + + suspend fun findUserByUsername(username: Username): KeycloakUser? { + val users = findUserByAttribute(username.asAttribute()) + return if (users.isEmpty()) null else users[0] + } + + private suspend fun findUserByAttribute(attr: Attribute): List { + val uri = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users?q=${attr.key}:${attr.value}" + + return keycloakClient.get() + .uri(uri) + .withAdminToken() + .retrieve() + .bodyToMono>() + .awaitFirstOrElse { emptyList() } + } + + private suspend fun findUser(uuid: String): UserResource? { + return opexRealm.users().get(uuid) + } + + suspend fun createUser( + username: Username, + firstName: String?, + lastName: String?, + enabled: Boolean + ) { + val keycloakUrl = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users" + val token = getAdminAccessToken() + val internalID = generateRandomInternalID() + val response = keycloakClient.post() + .uri(keycloakUrl) + .header("Content-Type", "application/json") + .withAdminToken(token) + .bodyValue( + hashMapOf( + "username" to internalID, + "emailVerified" to enabled, + "firstName" to firstName, + "lastName" to lastName, + "enabled" to enabled, + "attributes" to hashMapOf( + "kycLevel" to "0" + ).apply { + if (username.type == UsernameType.MOBILE) + put("mobile", username.value) + put(Attributes.OTP, OTPType.EMAIL.name + "," + OTPType.SMS.name) + } + ).apply { if (username.type == UsernameType.EMAIL) put("email", username.value) } + ) + .retrieve() + .onStatus({ it == HttpStatus.valueOf(409) }) { + throw OpexError.UserAlreadyExists.exception() + } + .toBodilessEntity() + .awaitSingle() + } + + suspend fun confirmCreateUser(user: KeycloakUser, password: String) { + val keycloakUrl = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${user.id}" + val token = getAdminAccessToken() + + keycloakClient.put() + .uri(keycloakUrl) + .header("Content-Type", "application/json") + .withAdminToken(token) + .bodyValue( + hashMapOf( + "emailVerified" to true, + "enabled" to true, + "credentials" to listOf( + mapOf( + "type" to "password", + "value" to password, + "temporary" to false + ) + ) + ) + ) + .retrieve() + .toBodilessEntity() + .awaitSingle() + } + + suspend fun assignRole(userId: String, role: UserRole) { + val roleRepresentation = opexRealm.roles().get(role.keycloakName).toRepresentation() + val userResource = opexRealm.users().get(userId) + userResource.roles().realmLevel().add(listOf(roleRepresentation)) + } + + suspend fun createExternalIdpUser(email: String, username: Username, password: String): String { + val userUrl = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users" + val userRequest = mapOf( + "username" to username.value, + "email" to email, + "emailVerified" to true, + "enabled" to true, + "credentials" to listOf( + mapOf( + "type" to "password", + "value" to password, + "temporary" to false + ) + ), + "attributes" to hashMapOf( + "kycLevel" to "0", + Attributes.OTP to username.type.otpType.name + ) + ) + + val response = keycloakClient.post() + .uri(userUrl) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${getAdminAccessToken()}") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(userRequest) + .retrieve() + .toBodilessEntity() + .awaitSingle() + + if (response.statusCode.isError) { + throw RuntimeException("Failed to create user in Keycloak") + } + + // Return the user ID (you may need to query Keycloak to get the user ID) + return findUserByEmail(email) + } + + suspend fun linkGoogleIdentity(userId: String, email: String, googleUserId: String) { + val identityUrl = + "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/$userId/federated-identity/google" + val identityRequest = mapOf( + "identityProvider" to "google", + "userId" to googleUserId, // Use the Google user ID from the token + "userName" to email // Use the Google email as the username + ) + + val response = keycloakClient.post() + .uri(identityUrl) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${getAdminAccessToken()}") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(identityRequest) + .retrieve() + .toBodilessEntity() + .awaitSingle() + + if (response.statusCode.isError) { + throw RuntimeException("Failed to link Google identity to Keycloak user") + } + } + + suspend fun logout(userId: String) { + val url = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${userId}/logout" + keycloakClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .withAdminToken() + .retrieve() + .toBodilessEntity() + .awaitSingleOrNull() + } + + suspend fun resetPassword(userId: String, newPassword: String) { + val url = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${userId}/reset-password" + val request = object { + val type = "password" + val value = newPassword + val temporary = false + } + keycloakClient.put() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .withAdminToken() + .bodyValue(request) + .retrieve() + .toBodilessEntity() + .awaitSingleOrNull() + } + + + suspend fun logoutSession(uuid: String, sessionId: String) { + val user = findUser(uuid) ?: throw OpexError.BadRequest.exception() + user.userSessions.find { it.id == sessionId } ?: OpexError.BadRequest.exception() + callLogout(sessionId) + } + + suspend fun logoutOthers(uuid: String, currentSessionId: String) { + val user = findUser(uuid) ?: throw OpexError.BadRequest.exception() + user.userSessions.forEach { + if (currentSessionId != it.id) + callLogout(it.id) + } + } + + suspend fun logoutAll(uuid: String) { + val user = findUser(uuid) ?: throw OpexError.BadRequest.exception() + //user.userSessions.forEach { opexRealm.deleteSession(it.id, true) } + user.logout() + } + + private suspend fun callLogout(sessionId: String) { + val url = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/sessions/$sessionId" + keycloakClient.delete() + .uri(url) + .withAdminToken() + .retrieve() + .toBodilessEntity() + .awaitSingleOrNull() + } + + private suspend fun WebClient.RequestHeadersSpec<*>.withAdminToken(token: String? = null): WebClient.RequestHeadersSpec<*> { + header(HttpHeaders.AUTHORIZATION, "Bearer ${token ?: getAdminAccessToken()}") + return this + } + + private suspend fun WebClient.RequestBodySpec.withAdminToken(token: String? = null): WebClient.RequestBodySpec { + header(HttpHeaders.AUTHORIZATION, "Bearer ${token ?: getAdminAccessToken()}") + return this + } + + suspend fun updateUserInfo( + userId: String, + newMobile: String? = null, + newEmail: String? = null, + firstName: String? = null, + lastName: String? = null + ) { + val updates = mutableMapOf() + + newEmail?.let { + updates["email"] = it + updates["emailVerified"] = true + } + + newMobile?.let { + updates["attributes"] = mapOf("mobile" to it) + } + + firstName?.let { updates["firstName"] = it } + lastName?.let { updates["lastName"] = it } + + if (updates.isNotEmpty()) { + updateUserFields(userId, updates) + } + } + + private suspend fun updateUserFields(userId: String, updates: Map) { + val url = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/$userId" + + val existingUser = keycloakClient.get() + .uri(url) + .withAdminToken() + .retrieve() + .bodyToMono>() + .awaitSingle() + .toMutableMap() + + updates.forEach { (key, value) -> + existingUser[key] = value + } + + keycloakClient.put() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .withAdminToken() + .bodyValue(existingUser) + .retrieve() + .toBodilessEntity() + .awaitSingleOrNull() + } + + + private suspend fun generateRandomInternalID(): String { + var internalId: String; + var attempts = 0 + do { + if (attempts >= 30) { + throw OpexError.InternalIdGenerateFailed.exception() + } + internalId = generateRandomID() + attempts++ + } while (findUserByAttribute(Attribute("username", internalId)).isNotEmpty()) + return internalId + + } + + suspend fun getClientBTokenWithBootstrap( + bootstrapToken: String, + clientId: String, + clientSecret: String?, + rememberMe: Boolean + ): Token { + // There is no way to define a custom grant type in keycloak, so we use a password grant with a custom Bootstrap token field, we defined a custom factory to pars this request + val tokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + + val token = keycloakClient.post() + .uri(tokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue( + "grant_type=password" + + "&client_id=$clientId" + + "&client_secret=$clientSecret" + + "&bootstrap_token=$bootstrapToken" + + "&username=bootstrap_user" + // Required dummy field + "&password=bootstrap_pass" // Required dummy field + ) + .retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { + throw OpexError.InvalidUserCredentials.exception() + } + .awaitBody() + + if (!rememberMe) token.refreshToken = null + return token + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxyV2.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxyV2.kt new file mode 100644 index 000000000..2efdaf55f --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxyV2.kt @@ -0,0 +1,74 @@ +package co.nilin.opex.auth.proxy + +import co.nilin.opex.auth.config.KeycloakConfig +import co.nilin.opex.auth.model.Attribute +import co.nilin.opex.auth.model.Token +import co.nilin.opex.auth.model.Username +import co.nilin.opex.common.OpexError +import org.keycloak.admin.client.Keycloak +import org.keycloak.admin.client.resource.RealmResource +import org.keycloak.representations.idm.UserRepresentation +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.HttpStatus +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody + +class KeycloakProxyV2( + private val keycloak: Keycloak, + private val opexRealm: RealmResource, + private val keycloakConfig: KeycloakConfig, + @Qualifier("keycloakWebClient") + private val client: WebClient +) { + + suspend fun getUserToken(username: Username, password: String, clientId: String, clientSecret: String): Token { + val user = findByUsername(username) ?: throw OpexError.InvalidUserCredentials.exception() + return getUserToken(user.username, password, clientId, clientSecret) + } + + fun findByUsername(username: Username): UserRepresentation? { + val users = findUserByAttribute(username.asAttribute()) + if (users.isEmpty()) + return null + return users[0] + } + + private suspend fun getUserToken( + username: String, + password: String?, + clientId: String, + clientSecret: String + ): Token { + val userTokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + return client.post() + .uri(userTokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue("client_id=${clientId}&client_secret=${clientSecret}&grant_type=password&username=${username}&password=${password}") + .retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { + throw OpexError.InvalidUserCredentials.exception() + } + .awaitBody() + } + + suspend fun refreshUserToken( + refreshToken: String, + clientId: String, + clientSecret: String + ): Token { + val userTokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + return client.post() + .uri(userTokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue("client_id=${clientId}&client_secret=${clientSecret}&grant_type=refresh_token&refresh_token=${refreshToken}") + .retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { + throw OpexError.InvalidUserCredentials.exception() + } + .awaitBody() + } + + private fun findUserByAttribute(attr: Attribute, exact: Boolean = true): List { + return opexRealm.users().searchByAttributes("${attr.key}:${attr.value}", exact) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/OTPProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/OTPProxy.kt new file mode 100644 index 000000000..3a81f4486 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/OTPProxy.kt @@ -0,0 +1,50 @@ +package co.nilin.opex.auth.proxy + +import co.nilin.opex.auth.model.OTPReceiver +import co.nilin.opex.auth.model.OTPVerifyRequest +import co.nilin.opex.auth.model.OTPVerifyResponse +import co.nilin.opex.auth.model.TempOtpResponse +import kotlinx.coroutines.reactive.awaitSingle +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody +import org.springframework.web.reactive.function.client.toEntity + +@Component +class OTPProxy(@Qualifier("otpWebClient") private val webClient: WebClient) { + + //TODO IMPORTANT: remove in production + + suspend fun requestOTP(userId: String, receivers: List): TempOtpResponse { + val request = object { + val userId = userId + val receivers = receivers + } + + return webClient.post().uri("/otp") + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(request)) + .retrieve() + .awaitBody() + } + + suspend fun verifyOTP(verifyRequest: OTPVerifyRequest): OTPVerifyResponse { + val request = object { + val userId = verifyRequest.userId + val otpCodes = verifyRequest.otpCodes.map { + object { + val type = it.otpType + val code = it.code + } + } + } + return webClient.post().uri("/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(request)) + .retrieve() + .awaitBody() + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt new file mode 100644 index 000000000..7856531b3 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt @@ -0,0 +1,67 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.kafka.AuthEventProducer +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.proxy.CaptchaProxy +import co.nilin.opex.auth.proxy.DeviceManagementProxy +import co.nilin.opex.auth.proxy.KeycloakProxy +import co.nilin.opex.auth.proxy.OTPProxy +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.stereotype.Service + +@Service +class ForgetPasswordService( + private val otpProxy: OTPProxy, + private val keycloakProxy: KeycloakProxy, + private val captchaProxy: CaptchaProxy, + private val authEventProducer: AuthEventProducer, + private val deviceManagementProxy: DeviceManagementProxy, + private val tempTokenService: TempTokenService +) { + + private val logger by LoggerDelegate() + + + suspend fun forgetPassword(request: ForgotPasswordRequest): TempOtpResponse { + captchaProxy.validateCaptcha( + request.captchaCode, + request.captchaType ?: CaptchaType.INTERNAL + ) + val uName = Username.create(request.username) + val otpReceiver = OTPReceiver(uName.value, uName.type.otpType) + val user = keycloakProxy.findUserByUsername(uName) ?: return TempOtpResponse("", otpReceiver) + //TODO IMPORTANT: remove in production + val result = otpProxy.requestOTP(uName.value, listOf(otpReceiver)) + return TempOtpResponse(result.otp, otpReceiver) + } + + suspend fun verifyForget(request: VerifyOTPRequest): String { + val username = Username.create(request.username) + val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) + val otpResult = otpProxy.verifyOTP(otpRequest) + if (!otpResult.result) { + when (otpResult.type) { + OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() + else -> throw OpexError.InvalidOTP.exception() + } + } + return tempTokenService.generateToken(username.value, OTPAction.FORGET) + } + + suspend fun confirmForget(request: ConfirmForgetRequest) { + if (request.newPassword != request.newPasswordConfirmation) + throw OpexError.InvalidPassword.exception() + + val data = tempTokenService.verifyToken(request.token) + if (!data.isValid || data.action != OTPAction.FORGET) + throw OpexError.InvalidRegisterToken.exception() + + val username = Username.create(data.userId) + val user = keycloakProxy.findUserByUsername(username) ?: return + + keycloakProxy.resetPassword(user.id, request.newPassword) + } + + +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt new file mode 100644 index 000000000..c41750589 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt @@ -0,0 +1,168 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.Device +import co.nilin.opex.auth.data.LoginEvent +import co.nilin.opex.auth.kafka.AuthEventProducer +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.proxy.CaptchaProxy +import co.nilin.opex.auth.proxy.GoogleProxy +import co.nilin.opex.auth.proxy.KeycloakProxy +import co.nilin.opex.auth.proxy.OTPProxy +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.security.JwtUtils +import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class LoginService( + private val otpProxy: OTPProxy, + private val keycloakProxy: KeycloakProxy, + private val googleProxy: GoogleProxy, + private val captchaProxy: CaptchaProxy, + private val authEventProducer: AuthEventProducer, + @Value("\${app.pre-auth-client-secret}") + private val preAuthClientSecretKey: String, +) { + private val logger by LoggerDelegate() + + private val PRE_AUTH_CLIENT_ID = "pre-auth-client" + + suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse { + captchaProxy.validateCaptcha( + request.captchaCode, + request.captchaType ?: CaptchaType.INTERNAL + ) + val username = Username.create(request.username) + val user = + keycloakProxy.findUserByUsername(username) ?: throw OpexError.UsernameOrPasswordIsIncorrect.exception() + val otpTypes = (user.attributes?.get(Attributes.OTP)?.get(0) ?: OTPType.NONE.name).split(",") + + if (otpTypes.contains(OTPType.NONE.name)) { + val token = keycloakProxy.getUserToken( + username, + request.password, + request.clientId, + request.clientSecret + ).apply { if (!request.rememberMe) refreshToken = null } + sendLoginEvent(user.id, token.sessionState, request, token.expiresIn) + return TokenResponse(token, null, null) + } + + keycloakProxy.checkUserCredentials(user, request.password) + + val token = keycloakProxy.getUserToken( + username, + request.password, + PRE_AUTH_CLIENT_ID, + preAuthClientSecretKey, + ).apply { + refreshToken = null + refreshExpiresIn = 0 + } + + + val usernameType = username.type.otpType + if (!otpTypes.contains((usernameType.name))) throw OpexError.OTPCannotBeRequested.exception() + val requiredOtpTypes = listOf(OTPReceiver(username.value, usernameType)) + val res = otpProxy.requestOTP(username.value, requiredOtpTypes) + val receiver = when (usernameType) { + OTPType.EMAIL -> user.email + OTPType.SMS -> user.mobile + else -> null + } + + + + return TokenResponse(token, RequiredOTP(usernameType, receiver), res.otp) + } + + suspend fun resendLoginOtp(request: ResendOtpRequest, uuid: String): ResendOtpResponse { + val username = Username.create(request.username) + val usernameType = username.type.otpType + val user = keycloakProxy.findUserByUsername(username) ?: throw OpexError.UserNotFound.exception() + if (user.id != uuid) throw OpexError.UnAuthorized.exception() + val requiredOtpTypes = listOf(OTPReceiver(username.value, usernameType)) + val res = otpProxy.requestOTP(request.username, requiredOtpTypes) + val receiver = when (usernameType) { + OTPType.EMAIL -> user.email + OTPType.SMS -> user.mobile + else -> null + } + return ResendOtpResponse(RequiredOTP(usernameType, receiver), res.otp) + + } + + + suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse { + val username = Username.create(request.username) + val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) + val otpResult = otpProxy.verifyOTP(otpRequest) + if (!otpResult.result) { + when (otpResult.type) { + OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() + else -> throw OpexError.InvalidOTP.exception() + } + } + +// val token = keycloakProxy.exchangeUserToken( +// request.token, request.clientId, +// request.clientSecret, +// request.clientId +// ).apply { if (!request.rememberMe) refreshToken = null } + val token = keycloakProxy.getClientBTokenWithBootstrap( + bootstrapToken = request.token, + clientId = request.clientId, + clientSecret = request.clientSecret, + rememberMe = request.rememberMe + ) + + sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request, token.expiresIn) + + return TokenResponse(token, null, null) + } + + suspend fun getToken(tokenRequest: ExternalIdpTokenRequest): TokenResponse { + val idToken = tokenRequest.idToken + val decodedJWT = googleProxy.validateGoogleToken(idToken) + val email = decodedJWT.getClaim("email").asString() + ?: throw IllegalArgumentException("Email not found in Google token") + try { + keycloakProxy.findUserByEmail(email) + } catch (e: Exception) { + throw OpexError.UsernameOrPasswordIsIncorrect.exception() + } + return TokenResponse( + keycloakProxy.exchangeGoogleTokenForKeycloakToken( + tokenRequest.accessToken + ), null, null + ) + } + + suspend fun refreshToken(request: RefreshTokenRequest): TokenResponse { + val token = keycloakProxy.refreshUserToken(request.refreshToken, request.clientId, request.clientSecret) + sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request, token.expiresIn) + return TokenResponse(token, null, null) + } + + private fun sendLoginEvent(userId: String, sessionState: String?, request: Device, expiresIn: Int) { + authEventProducer.send( + LoginEvent( + userId, + sessionState, + request.deviceUuid, + request.appVersion, + request.osVersion, + LocalDateTime.now().plusSeconds(expiresIn.toLong()), + request.os + ) + ) + } + + + private fun extractUserUuidFromToken(token: String): String { + return JwtUtils.decodePayload(token)["sub"].toString() + } + +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LogoutService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LogoutService.kt new file mode 100644 index 000000000..38e7b2c15 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LogoutService.kt @@ -0,0 +1,42 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.LogoutEvent +import co.nilin.opex.auth.kafka.AuthEventProducer +import co.nilin.opex.auth.proxy.KeycloakProxy +import org.springframework.stereotype.Service + +@Service +class LogoutService( + private val keycloakProxy: KeycloakProxy, + private val authEventProducer: AuthEventProducer, +) { + + suspend fun logout(userId: String, sessionId: String) { + keycloakProxy.logoutSession(userId, sessionId) + sendLogoutEvent(userId, sessionId) + } + + suspend fun logoutSession(uuid: String, sessionId: String) { + keycloakProxy.logoutSession(uuid, sessionId) + } + + suspend fun logoutOthers(uuid: String, currentSessionId: String) { + keycloakProxy.logoutOthers(uuid, currentSessionId) + sendLogoutEvent(uuid, currentSessionId, true) + } + + suspend fun logoutAll(uuid: String) { + keycloakProxy.logoutAll(uuid) + } + + + private fun sendLogoutEvent(userId: String, sessionState: String?, others: Boolean? = false) { + authEventProducer.send( + LogoutEvent( + userId, + sessionState, + others + ) + ) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/RegisterService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/RegisterService.kt new file mode 100644 index 000000000..4f95d9e40 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/RegisterService.kt @@ -0,0 +1,126 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.Device +import co.nilin.opex.auth.data.LoginEvent +import co.nilin.opex.auth.data.UserCreatedEvent +import co.nilin.opex.auth.data.UserRole +import co.nilin.opex.auth.kafka.AuthEventProducer +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.proxy.CaptchaProxy +import co.nilin.opex.auth.proxy.GoogleProxy +import co.nilin.opex.auth.proxy.KeycloakProxy +import co.nilin.opex.auth.proxy.OTPProxy +import co.nilin.opex.common.OpexError +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class RegisterService( + private val otpProxy: OTPProxy, + private val keycloakProxy: KeycloakProxy, + private val captchaProxy: CaptchaProxy, + private val googleProxy: GoogleProxy, + private val authProducer: AuthEventProducer, + private val tempTokenService: TempTokenService + +) { + //TODO IMPORTANT: remove in production + suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse { + captchaProxy.validateCaptcha( + request.captchaCode, + request.captchaType ?: CaptchaType.INTERNAL + ) + val username = Username.create(request.username) + val userStatus = isUserDuplicate(username) + + val otpType = username.type.otpType + val otpReceiver = OTPReceiver(request.username, otpType) + val res = otpProxy.requestOTP(request.username, listOf(otpReceiver)) +// todo we have to check for duplication usernames after verifying the register otp + if (!userStatus) + keycloakProxy.createUser( + username, + request.firstName, + request.lastName, + false + ) + return TempOtpResponse(res.otp, otpReceiver) + } + + suspend fun verifyRegister(request: VerifyOTPRequest): String { + val username = Username.create(request.username) + val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) + val otpResult = otpProxy.verifyOTP(otpRequest) + if (!otpResult.result) { + when (otpResult.type) { + OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() + else -> throw OpexError.InvalidOTP.exception() + } + } + return tempTokenService.generateToken(username.value, OTPAction.REGISTER) + } + + suspend fun confirmRegister(request: ConfirmRegisterRequest): Token? { + val data = tempTokenService.verifyToken(request.token) + if (!data.isValid || data.action != OTPAction.REGISTER) + throw OpexError.InvalidRegisterToken.exception() + + val username = Username.create(data.userId) + val user = keycloakProxy.findUserByUsername(username) + if (user == null || user.enabled) + throw OpexError.BadRequest.exception() + + keycloakProxy.confirmCreateUser(user, request.password) + keycloakProxy.assignRole(user.id, UserRole.LEVEL_1) + + // Send event to let other services know a user just registered + val event = UserCreatedEvent(user.id, user.username, user.email, user.mobile, user.firstName, user.lastName) + authProducer.send(event) + + return if (request.clientId.isNullOrBlank() || request.clientSecret.isNullOrBlank()) + null + else { + val token = keycloakProxy.getUserToken(username, request.password, request.clientId, request.clientSecret) + sendLoginEvent(user.id, token.sessionState, request, token.expiresIn) + return token + } + } + + suspend fun registerExternalIdpUser(externalIdpUserRegisterRequest: ExternalIdpUserRegisterRequest) { + val decodedJWT = googleProxy.validateGoogleToken(externalIdpUserRegisterRequest.idToken) + val email = decodedJWT.getClaim("email").asString() + ?: throw OpexError.GmailNotFoundInToken.exception() + val googleUserId = decodedJWT.getClaim("sub").asString() + ?: throw OpexError.UserIDNotFoundInToken.exception() + + val username = Username.create(email) // Use email as the username + isUserDuplicate(username) + + val userId = keycloakProxy.createExternalIdpUser(email, username, externalIdpUserRegisterRequest.password) + keycloakProxy.linkGoogleIdentity(userId, email, googleUserId) + } + + private fun sendLoginEvent(userId: String, sessionState: String?, request: Device, expiresIn: Int) { + authProducer.send( + LoginEvent( + userId, + sessionState, + request.deviceUuid, + request.appVersion, + request.osVersion, + LocalDateTime.now().plusSeconds(expiresIn.toLong()), + request.os + ) + ) + } + + private suspend fun isUserDuplicate(username: Username): Boolean { + val user = keycloakProxy.findUserByUsername(username) + return if (user == null) + false + else if (!user.enabled) + return true + else + throw OpexError.UserAlreadyExists.exception() + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/SessionService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/SessionService.kt new file mode 100644 index 000000000..fe167ae37 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/SessionService.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.SessionRequest +import co.nilin.opex.auth.data.Sessions +import co.nilin.opex.auth.proxy.DeviceManagementProxy +import org.springframework.stereotype.Service + +@Service +class SessionService (private val deviceManagementProxy: DeviceManagementProxy){ + suspend fun fetchSessions(sessionRequest: SessionRequest, currentSessionId: String): List { + return deviceManagementProxy.getLastSessions(sessionRequest).stream() + .map { if (it.sessionState == currentSessionId) it.apply { isCurrentSession = true } else it }.toList() + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TempTokenService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TempTokenService.kt new file mode 100644 index 000000000..38fcb91d2 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TempTokenService.kt @@ -0,0 +1,49 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.model.OTPAction +import co.nilin.opex.auth.model.TokenData +import co.nilin.opex.common.utils.LoggerDelegate +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import org.springframework.stereotype.Service +import java.security.PrivateKey +import java.security.PublicKey +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +@Service +class TempTokenService( + private val privateKey: PrivateKey, + private val publicKey: PublicKey, +) { + private val logger by LoggerDelegate() + + fun generateToken(userId: String, action: OTPAction): String { + val issuedAt = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()) + val exp = Date.from(LocalDateTime.now().plusMinutes(2).atZone(ZoneId.systemDefault()).toInstant()) + return Jwts.builder() + .issuer("opex-auth") + .claim("userId", userId) + .claim("action", action) + .issuedAt(issuedAt) + .expiration(exp) + .signWith(privateKey) + .compact() + } + + fun verifyToken(token: String): TokenData { + try { + val claims = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .payload + return TokenData(true, claims["userId"] as String, OTPAction.valueOf(claims["action"] as String)) + } catch (e: JwtException) { + logger.error("Could not verify token", e) + return TokenData(false, "", OTPAction.REGISTER) + } + } + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/spi/KycLevelUpdatedEventListener.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/spi/KycLevelUpdatedEventListener.kt new file mode 100644 index 000000000..63d551ccf --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/spi/KycLevelUpdatedEventListener.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.spi + +import co.nilin.opex.auth.data.KycLevelUpdatedEvent + + +interface KycLevelUpdatedEventListener { + fun id(): String + fun onEvent(event: KycLevelUpdatedEvent, partition: Int, offset: Long, timestamp: Long, eventId: String) + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/spi/ProfileUpdatedEventListener.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/spi/ProfileUpdatedEventListener.kt new file mode 100644 index 000000000..f5c715c37 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/spi/ProfileUpdatedEventListener.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.auth.spi + +import co.nilin.opex.auth.data.ProfileUpdatedEvent + + +interface ProfileUpdatedEventListener { + fun id(): String + fun onEvent(event: ProfileUpdatedEvent, partition: Int, offset: Long, timestamp: Long, eventId: String) + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/AudienceValidator.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/AudienceValidator.kt new file mode 100644 index 000000000..3208d9324 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.auth.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/InternalIdGenerator.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/InternalIdGenerator.kt new file mode 100644 index 000000000..7908e9ef0 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/InternalIdGenerator.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.auth.utils + +import kotlin.random.Random + +fun generateRandomID(): String { + val digits = IntArray(6) { Random.nextInt(0, 10) } + val sum = digits.sum() + val checksum = sum.toString().padStart(2, '0') + return buildString(8) { + digits.forEach { append(it) } + append(checksum) + } +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/PrometheusHealthExtension.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/PrometheusHealthExtension.kt new file mode 100644 index 000000000..ef69510e5 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/PrometheusHealthExtension.kt @@ -0,0 +1,63 @@ +package co.nilin.opex.auth.utils + +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.boot.actuate.health.HealthComponent +import org.springframework.boot.actuate.health.HealthEndpoint +import org.springframework.boot.actuate.health.SystemHealth +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class PrometheusHealthExtension( + private val registry: MeterRegistry, + private val endpoint: HealthEndpoint +) { + + private var consulHealth = -1 + private var r2dbcHealth = -1 + private var vaultHealth = -1 + private var vaultReactiveHealth = -1 + private val service = "AUTH_GATEWAY" + + init { + Gauge.builder("consul_health", consulHealth) { consulHealth.toDouble() } + .description("Health of consul connection") + .tag("Service", service) + .register(registry) + + Gauge.builder("r2dbc_health", r2dbcHealth) { r2dbcHealth.toDouble() } + .description("Health of r2dbc connection") + .tag("Service", service) + .register(registry) + + Gauge.builder("vault_health", vaultHealth) { vaultHealth.toDouble() } + .description("Health of vault connection") + .tag("Service", service) + .register(registry) + + Gauge.builder("vaultReactive_health", vaultReactiveHealth) { vaultReactiveHealth.toDouble() } + .description("Health of vaultReactive connection") + .tag("Service", service) + .register(registry) + } + + @Scheduled(initialDelay = 1000, fixedDelay = 5000) + fun updateHealth() { + try { + val health = endpoint.health() as SystemHealth + consulHealth = getHealthValue(health.components["consul"]) + r2dbcHealth = getHealthValue(health.components["r2dbc"]) + vaultHealth = getHealthValue(health.components["vault"]) + vaultReactiveHealth = getHealthValue(health.components["vaultReactive"]) + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun getHealthValue(health: HealthComponent?): Int { + health ?: return -1 + return if (health.status.code == "UP") 1 else 0 + } + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/UsernameValidator.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/UsernameValidator.kt new file mode 100644 index 000000000..724dc2a00 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/UsernameValidator.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.auth.utils + +import co.nilin.opex.auth.model.UsernameType +import jakarta.mail.internet.InternetAddress +import java.util.regex.Pattern + +object UsernameValidator { + + private val mobileRegex = Pattern.compile("^\\d{10,15}$") + + fun getType(username: String): UsernameType { + if (isValidEmail(username)) + return UsernameType.EMAIL + + if (isValidMobile(username)) + return UsernameType.MOBILE + + return UsernameType.UNKNOWN + } + + fun isValidMobile(input: String): Boolean { + return mobileRegex.matcher(input).matches() + } + + fun isValidEmail(input: String): Boolean { + return try { + InternetAddress(input).validate() + true + } catch (e: Exception) { + false + } + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/resources/application.yml b/auth-gateway/auth-gateway-app/src/main/resources/application.yml new file mode 100644 index 000000000..04ceb4f9e --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/resources/application.yml @@ -0,0 +1,101 @@ +server: + port: 8080 + forward-headers-strategy: native + +spring: + application: + name: opex-auth-gateway + kafka: + bootstrap-servers: ${KAFKA_IP_PORT:localhost:9092} + consumer: + group-id: auth + cloud: + bootstrap: + enabled: true + consul: + host: ${CONSUL_HOST:localhost} + port: 8500 + discovery: + #healthCheckPath: ${management.context-path}/health + instance-id: ${spring.application.name}:${server.port} + healthCheckInterval: 20s + prefer-ip-address: true +management: + endpoints: + web: + base-path: /actuator + exposure: + include: [ "health", "prometheus", "metrics", "loggers" ] +logbook: + secure-filter: + enabled: true + format: + style: http + filter: + enabled: true + form-request-mode: BODY + attribute-extractors: + - type: JwtFirstMatchingClaimExtractor + claim-names: [ "sub", "subject" ] + obfuscate: + headers: + - Authorization + parameters: + - password + json-body-fields: + - password + replacement: "***" + write: + max-body-size: 500 #kb + predicate: + exclude: + - path: /auth** + - path: /actuator/** + - path: /swagger** + - path: /config/** +logging: + level: + co.nilin: INFO + org.zalando.logbook: TRACE +keycloak: + url: http://keycloak:8080 + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} + realm: opex + admin-client: + id: "opex-admin" + secret: ${ADMIN_CLIENT_SECRET} + google-client-id: ${GOOGLE_CLIENT_ID} +app: + otp: + url: http://opex-otp/v1 + captcha: + url: http://opex-captcha + enabled: ${CAPTCHA_ENABLED:true} + device-management: + url: http://opex-device-management + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} + pre-auth-client-secret: ${PRE_AUTH_CLIENT_SECRET} + cors: + enabled: true + allowed-origins: ${ALLOWED_ORIGINS:"http://localhost:8110"} + openapi: + server-url: ${OPEN_API_SERVER_URL} +# --- 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/auth-gateway/docker-compose.yml b/auth-gateway/docker-compose.yml new file mode 100644 index 000000000..5246b65ed --- /dev/null +++ b/auth-gateway/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + keycloak: + image: quay.io/keycloak/keycloak:26.1 + container_name: keycloak + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KC_HEALTH_ENABLED: true + KC_METRICS_ENABLED: true + GOOGLE_CLIENT_ID: 625813606110-er3v3sol4v206kdg40gf0ltqv08scgs2.apps.googleusercontent.com + GOOGLE_CLIENT_SECRET: "*************" + command: + - start-dev + - --import-realm + - --features=admin-fine-grained-authz,token-exchange + volumes: + - ./keycloak-setup/realms:/opt/keycloak/data/import + ports: + - "8080:8080" + depends_on: + - postgres + networks: + - keycloak-network + + postgres: + image: postgres:15 + container_name: postgres + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - keycloak-network + +volumes: + postgres_data: + +networks: + keycloak-network: + driver: bridge \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/Dockerfile b/auth-gateway/keycloak-setup/Dockerfile new file mode 100644 index 000000000..8ecca1eb9 --- /dev/null +++ b/auth-gateway/keycloak-setup/Dockerfile @@ -0,0 +1,10 @@ +FROM maven:3.9.6-eclipse-temurin-21 AS builder +WORKDIR /build/spi +COPY spi/pom.xml . +RUN mvn dependency:go-offline +COPY spi/src ./src +RUN mvn clean install + +FROM quay.io/keycloak/keycloak:26.1 +COPY --from=builder /build/spi/target/*.jar /opt/keycloak/providers/ +COPY realms/ /opt/keycloak/data/import/ \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/config/keycloak-server.json b/auth-gateway/keycloak-setup/config/keycloak-server.json new file mode 100644 index 000000000..6c6b39cc1 --- /dev/null +++ b/auth-gateway/keycloak-setup/config/keycloak-server.json @@ -0,0 +1,215 @@ +{ + "hostname": { + "provider": "${keycloak.hostname.provider:default}", + "fixed": { + "hostname": "${keycloak.hostname.fixed.hostname:localhost}", + "httpPort": "${keycloak.hostname.fixed.httpPort:-1}", + "httpsPort": "${keycloak.hostname.fixed.httpsPort:-1}", + "alwaysHttps": "${keycloak.hostname.fixed.alwaysHttps:false}" + }, + "default": { + "frontendUrl": "${keycloak.frontendUrl:}", + "adminUrl": "${keycloak.adminUrl:}", + "forceBackendUrlToFrontendUrl": "${keycloak.hostname.default.forceBackendUrlToFrontendUrl:false}" + } + }, + "admin": { + "realm": "master" + }, + "eventsStore": { + "provider": "${keycloak.eventsStore.provider:jpa}", + "jpa": { + "max-detail-length": "${keycloak.eventsStore.maxDetailLength:1000}" + } + }, + "eventsListener": { + "jboss-logging": { + "success-level": "debug", + "error-level": "warn" + }, + "event-queue": {} + }, + "realm": { + "provider": "${keycloak.realm.provider:jpa}" + }, + "user": { + "provider": "${keycloak.user.provider:jpa}" + }, + "client": { + "provider": "${keycloak.client.provider:jpa}" + }, + "clientScope": { + "provider": "${keycloak.clientScope.provider:jpa}" + }, + "group": { + "provider": "${keycloak.group.provider:jpa}" + }, + "role": { + "provider": "${keycloak.role.provider:jpa}" + }, + "authenticationSessions": { + "provider": "${keycloak.authSession.provider:infinispan}" + }, + "mapStorage": { + "provider": "${keycloak.mapStorage.provider:concurrenthashmap}", + "concurrenthashmap": { + "dir": "${project.build.directory:target}" + } + }, + "userFederatedStorage": { + "provider": "${keycloak.userFederatedStorage.provider:jpa}" + }, + "userSessionPersister": { + "provider": "${keycloak.userSessionPersister.provider:jpa}" + }, + "authorizationPersister": { + "provider": "${keycloak.authorization.provider:jpa}" + }, + "userCache": { + "provider": "${keycloak.user.cache.provider:default}", + "default": { + "enabled": true + }, + "mem": { + "maxSize": 20000 + } + }, + "userSessions": { + "provider": "${keycloak.userSessions.provider:infinispan}" + }, + "timer": { + "provider": "basic" + }, + "theme": { + "staticMaxAge": "${keycloak.theme.staticMaxAge:2592000}", + "cacheTemplates": "${keycloak.theme.cacheTemplates:true}", + "cacheThemes": "${keycloak.theme.cacheThemes:true}", + "folder": { + "dir": "${keycloak.theme.dir}" + } + }, + "login": { + "provider": "freemarker" + }, + "account": { + "provider": "freemarker" + }, + "email": { + "provider": "freemarker" + }, + "scheduled": { + "interval": 900 + }, + "connectionsHttpClient": { + "default": { + "reuse-connections": false + } + }, + "connectionsJpa": { + "default": { + "url": "${spring.datasource.url}", + "driver": "${spring.datasource.driver-class-name}", + "driverDialect": "${spring.jpa.properties.hibernate.dialect}", + "user": "${spring.datasource.username}", + "password": "${spring.datasource.password}", + "initializeEmpty": true, + "migrationStrategy": "update", + "showSql": "true", + "formatSql": "true", + "globalStatsInterval": "-1" + } + }, + "realmCache": { + "provider": "${keycloak.realm.cache.provider:default}", + "default": { + "enabled": true + } + }, + "connectionsInfinispan": { + "default": { + "jgroupsUdpMcastAddr": "${keycloak.connectionsInfinispan.jgroupsUdpMcastAddr:234.56.78.90}", + "nodeName": "${keycloak.connectionsInfinispan.nodeName,jboss.node.name:}", + "siteName": "${keycloak.connectionsInfinispan.siteName,jboss.site.name:}", + "clustered": "${keycloak.connectionsInfinispan.clustered:false}", + "async": "${keycloak.connectionsInfinispan.async:false}", + "sessionsOwners": "${keycloak.connectionsInfinispan.sessionsOwners:1}", + "l1Lifespan": "${keycloak.connectionsInfinispan.l1Lifespan:600000}", + "remoteStoreEnabled": "${keycloak.connectionsInfinispan.remoteStoreEnabled:false}", + "remoteStoreHost": "${keycloak.connectionsInfinispan.remoteStoreServer:localhost}", + "remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort:11222}", + "hotrodProtocolVersion": "${keycloak.connectionsInfinispan.hotrodProtocolVersion}", + "embedded": "${keycloak.connectionsInfinispan.embedded:true}" + } + }, + "truststore": { + "file": { + "disabled": "${keycloak.truststore.disabled:true}" + } + }, + "jta-lookup": { + "provider": "${keycloak.jta.lookup.provider:jboss}", + "jboss": { + "enabled": true + } + }, + "login-protocol": { + "saml": { + "knownProtocols": [ + "http=${auth.server.http.port}", + "https=${auth.server.https.port}" + ] + } + }, + "userProfile": { + "legacy-user-profile": { + "read-only-attributes": [ + "deniedFoo", + "deniedBar*", + "deniedSome/thing", + "deniedsome*thing" + ], + "admin-read-only-attributes": [ + "deniedSomeAdmin" + ] + } + }, + "x509cert-lookup": { + "provider": "${keycloak.x509cert.lookup.provider:default}", + "default": { + "enabled": true + }, + "haproxy": { + "enabled": true, + "sslClientCert": "x-ssl-client-cert", + "sslCertChainPrefix": "x-ssl-client-cert-chain", + "certificateChainLength": 1 + }, + "apache": { + "enabled": true, + "sslClientCert": "x-ssl-client-cert", + "sslCertChainPrefix": "x-ssl-client-cert-chain", + "certificateChainLength": 1 + }, + "nginx": { + "enabled": true, + "sslClientCert": "x-ssl-client-cert", + "sslCertChainPrefix": "x-ssl-client-cert-chain", + "certificateChainLength": 1 + } + }, + "vault": { + "provider": "hachicorp-vault", + "default": { + "enabled": true + }, + "hachicorp-vault": { + "url": "${keycloak.hashicorp.url}", + "appId": "${spring.application.name}", + "engine-name": "secret", + "enabled": "${keycloak.vault.files-plaintext.provider.enabled:true}" + } + }, + "saml-artifact-resolver": { + "provider": "${keycloak.saml-artifact-resolver.provider:default}" + } +} diff --git a/auth-gateway/keycloak-setup/realms/opex-master-realm.json b/auth-gateway/keycloak-setup/realms/opex-master-realm.json new file mode 100644 index 000000000..146a5976d --- /dev/null +++ b/auth-gateway/keycloak-setup/realms/opex-master-realm.json @@ -0,0 +1,47 @@ +{ + "id": "master", + "realm": "master", + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "none", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "smtpServer": { + "host": "smtp.elasticemail.com", + "port": 2525, + "from": "for.demo.purpose.only@opex.dev", + "auth": true, + "user": "for.demo.purpose.only@opex.dev", + "password": "${vault.smtppass}" + } +} \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/realms/opex-realm.json b/auth-gateway/keycloak-setup/realms/opex-realm.json new file mode 100644 index 000000000..c26448246 --- /dev/null +++ b/auth-gateway/keycloak-setup/realms/opex-realm.json @@ -0,0 +1,4118 @@ +{ + "id": "opex", + "realm": "opex", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 1800, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1209600, + "ssoSessionMaxLifespan": 1209600, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "none", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "01633d43-b1b2-4c6d-a987-e8a88664ed5c", + "name": "super-admin", + "description": "", + "composite": true, + "composites": { + "realm": [ + "admin" + ] + }, + "clientRole": false, + "containerId": "opex", + "attributes": {} + }, + { + "id": "77c38736-27cc-4787-b83c-6b30872df6f7", + "name": "user-1", + "description": "Base user role", + "composite": true, + "composites": { + "realm": [ + "user" + ] + }, + "clientRole": false, + "containerId": "opex", + "attributes": { + "permissions": [ + "deposit:write" + ] + } + }, + { + "id": "7a788c40-fb3f-47e6-bdbe-2004b999afb6", + "name": "user", + "description": "Default role for all users", + "composite": false, + "clientRole": false, + "containerId": "opex", + "attributes": { + "permissions": [ + "order:write,address:assign" + ] + } + }, + { + "id": "1135b8ef-3838-4397-961e-79a77845fac2", + "name": "impersonation", + "composite": false, + "clientRole": false, + "containerId": "opex", + "attributes": {} + }, + { + "id": "67844e9a-9943-4e18-b05a-775943347188", + "name": "default-roles-opex", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account", + "view-profile" + ] + } + }, + "clientRole": false, + "containerId": "opex", + "attributes": {} + }, + { + "id": "3b6109f5-6e5a-4578-83c3-791ec3e2bf9e", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "opex", + "attributes": {} + }, + { + "id": "0dd6a8c7-d669-4941-9ea1-521980e9c53f", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "opex", + "attributes": {} + }, + { + "id": "904fce7c-bea5-43ed-8526-580370b1827e", + "name": "user-2", + "description": "", + "composite": true, + "composites": { + "realm": [ + "user" + ] + }, + "clientRole": false, + "containerId": "opex", + "attributes": { + "permissions": [ + "withdraw:write,voucher:submit" + ] + } + }, + { + "id": "99bad44d-9a1b-403f-9b90-2fc81646adf6", + "name": "admin", + "description": "Base role for all admin users", + "composite": false, + "clientRole": false, + "containerId": "opex", + "attributes": {} + } + ], + "client": { + "web-app": [], + "realm-management": [ + { + "id": "5d00243f-ceec-4b0c-995e-d86d5b8a0ae6", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "7df58488-6470-4f4e-962d-2900019e9906", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "941612de-bd85-47a5-8dfa-37c270dde28c", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "5ea9810d-63cc-4277-9b32-ba8a3d3c6091", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "8b7b0dd8-350b-473e-b8cd-8acad34f1358", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "0f8e5ee8-b014-4b7c-9b69-50f46abcba5f", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "911b1489-9383-4734-b134-bf49bf992ce9", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "5d48274c-bd6b-4c26-ad54-f1a2254beac0", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "3ea43b64-316f-4693-8346-9ee78b24adaf", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "49735614-96ec-49b2-98fe-3af9bcd1a33a", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "e8f8c3cc-0ff1-4f72-a271-db6821a3cdb6", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "387418b1-4f80-4b00-b9dd-805ca041f805", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "427c27d4-521a-464b-a0df-16d7f537e8d5", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "view-clients", + "view-authorization", + "manage-realm", + "query-clients", + "query-groups", + "manage-clients", + "view-realm", + "manage-identity-providers", + "create-client", + "manage-users", + "view-identity-providers", + "query-users", + "query-realms", + "view-users", + "impersonation", + "manage-authorization", + "manage-events", + "view-events" + ] + } + }, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "a574cf01-03e4-4573-ab9e-276d13a1ce8d", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "c3a253a8-a1b6-4d38-9677-f728f32482ad", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "f3cb93da-273e-419a-b2f4-93f09896abcf", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "0332e99b-3dfc-4193-9e13-5728f8f3e6d6", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "6eedf2b7-50ef-4495-a89b-54aef751b7fa", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "aac3def5-f193-4a6c-9065-1667a0746a8a", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + }, + { + "id": "b690cb9c-0f4a-4be5-ade0-b40443d8149d", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "6a4bfbd0-576d-4778-af56-56f876647355", + "attributes": {} + } + ], + "ios-app": [], + "opex-api-key": [ + { + "id": "95d01e3b-1442-415c-9f86-4d86187558ca", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "android-app": [], + "broker": [ + { + "id": "397b5703-4c81-48fd-a24c-a7e8177ef657", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "4b9609f0-48d1-4e71-9381-2ecec08616f9", + "attributes": {} + } + ], + "opex-admin": [ + { + "id": "cd0dca5f-aa89-4a97-8cb7-6525f919c3b6", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "fb5f91c4-42fa-4769-b45d-febef22b4976", + "attributes": {} + } + ], + "account": [ + { + "id": "8daa8096-d14e-4d1c-ad1f-83f822016aa1", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + }, + { + "id": "33f3dfbc-323f-4a01-9539-bcba0d06f3bc", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + }, + { + "id": "cc86369d-55fc-47ed-9592-9e89116032c0", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + }, + { + "id": "ca726012-91f9-4c58-bc7e-e435c9c244ab", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + }, + { + "id": "f6839de1-e7fc-42bb-be60-578ea3d9361b", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + }, + { + "id": "948269c7-a69c-4c82-a7f3-88868713dfd9", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + }, + { + "id": "ee9cf17f-b9b1-4f4a-b521-83b5d1d7388b", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + }, + { + "id": "aed18201-2433-4998-8fa3-0979b0b31c10", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "attributes": {} + } + ] + } + }, + "groups": [ + { + "id": "700a0042-6146-42fc-a97a-f0f63f913301", + "name": "admins", + "path": "/admins", + "subGroups": [], + "attributes": {}, + "realmRoles": [], + "clientRoles": {} + } + ], + "defaultRole": { + "id": "67844e9a-9943-4e18-b05a-775943347188", + "name": "default-roles-opex", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "opex" + }, + "requiredCredentials": [ + "password" + ], + "passwordPolicy": "length(8)", + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 5, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "cb6af759-5e4f-42b3-86ea-b3754ce4d422", + "username": "service-account-account-console", + "emailVerified": false, + "createdTimestamp": 1624136397065, + "enabled": true, + "totp": false, + "serviceAccountClientId": "account-console", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "offline_access", + "uma_authorization" + ], + "clientRoles": { + "realm-management": [ + "manage-users" + ], + "account": [ + "manage-account", + "view-profile" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "2cda7f75-c5d9-4b64-b90e-58b381689a9d", + "username": "service-account-opex-admin", + "emailVerified": false, + "createdTimestamp": 1643624421752, + "enabled": true, + "totp": false, + "serviceAccountClientId": "opex-admin", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "offline_access", + "uma_authorization" + ], + "clientRoles": { + "realm-management": [ + "realm-admin", + "manage-users" + ], + "opex-admin": [ + "uma_protection" + ], + "account": [ + "manage-account", + "view-profile" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "4bb55f60-faa0-464a-b0a3-04a18f421dff", + "username": "service-account-opex-api-key", + "emailVerified": false, + "createdTimestamp": 1749460715817, + "enabled": true, + "totp": false, + "serviceAccountClientId": "opex-api-key", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-opex" + ], + "clientRoles": { + "opex-api-key": [ + "uma_protection" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "854a5e2b-1e45-4ff7-bd1c-d9764d41a5bd", + "username": "service-account-realm-management", + "emailVerified": false, + "createdTimestamp": 1634844207750, + "enabled": true, + "totp": false, + "serviceAccountClientId": "realm-management", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "offline_access", + "uma_authorization" + ], + "clientRoles": { + "realm-management": [ + "uma_protection" + ], + "account": [ + "manage-account", + "view-profile" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "client": "account-console", + "roles": [ + "offline_access", + "uma_authorization" + ] + }, + { + "client": "opex-admin", + "roles": [ + "impersonation", + "offline_access", + "uma_authorization" + ] + }, + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "realm-management": [ + { + "client": "account-console", + "roles": [ + "impersonation", + "realm-admin", + "manage-users" + ] + }, + { + "client": "admin-cli", + "roles": [ + "impersonation" + ] + }, + { + "client": "opex-admin", + "roles": [ + "realm-admin" + ] + } + ], + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + }, + { + "client": "opex-admin", + "roles": [ + "manage-account" + ] + } + ] + }, + "clients": [ + { + "id": "12eebf0b-a3eb-49f8-9ecf-173cf8a00145", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/opex/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/realms/opex/account/*", + "http://localhost:3000/*" + ], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "saml.server.signature": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "realm_client": "false", + "backchannel.logout.session.required": "false", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "trust", + "web-origins", + "acr", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/opex/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/realms/opex/account/*", + "http://localhost:3000/*", + "https://opex.dev/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "saml.server.signature": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "realm_client": "false", + "backchannel.logout.session.required": "false", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "pkce.code.challenge.method": "S256", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "b83a852e-e3e3-46dc-9e76-3d175fc4a5d4", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "6b335962-bc9b-4095-ad36-48163e443a6f", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "4c6842a7-9120-49b4-92eb-6cd4a3ff742d", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "6551bb7e-54af-455e-8de6-0e5acb1b3527", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "trust", + "acr", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "13d76feb-d762-4409-bb84-7a75bc395a61", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "request.object.signature.alg": "any", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "false", + "client_credentials.use_refresh_token": "false", + "saml.client.signature": "false", + "require.pushed.authorization.requests": "false", + "request.object.encryption.enc": "any", + "saml.assertion.signature": "false", + "client.secret.creation.time": "1749563219", + "request.object.encryption.alg": "any", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "exclude.session.state.from.auth.response": "false", + "client.use.lightweight.access.token.enabled": "true", + "request.object.required": "not required", + "saml_force_name_id_format": "false", + "tls.client.certificate.bound.access.tokens": "false", + "acr.loa.map": "{}", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "c652a15d-6b9d-4f11-af40-ed451f30589c", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "0cede09c-fee1-4e60-85fa-7067180bbae5", + "name": "Group Mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + }, + { + "id": "5111c406-34fd-499f-b8b4-9603bc1e686c", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "f53d8370-145f-4123-a680-abe4e7ef5d39", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "a74e3676-91f1-4897-830c-14a2fad94c73", + "name": "UserLeveL", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "level", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "level", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + } + ], + "defaultClientScopes": [ + "trust", + "web-origins", + "acr", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "30c68e47-1e37-4ce4-a323-efec3dcec65c", + "clientId": "android-app", + "name": "Android app", + "description": "Client for opex android app", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "600", + "request.object.signature.alg": "any", + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "exclude.issuer.from.auth.response": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "require.pushed.authorization.requests": "false", + "request.object.encryption.enc": "any", + "client.session.max.lifespan": "604800", + "client.secret.creation.time": "1747492073", + "request.object.encryption.alg": "any", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "exclude.session.state.from.auth.response": "false", + "client.use.lightweight.access.token.enabled": "false", + "request.object.required": "not required", + "tls.client.certificate.bound.access.tokens": "false", + "acr.loa.map": "{}", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "trust", + "basic", + "role_permission_attribute" + ], + "optionalClientScopes": [] + }, + { + "id": "4b9609f0-48d1-4e71-9381-2ecec08616f9", + "clientId": "broker", + "name": "${client_broker}", + "rootUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "http://localhost:3000/*", + "https://opex.dev/*" + ], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "saml.server.signature": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "realm_client": "true", + "backchannel.logout.session.required": "false", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "trust", + "web-origins", + "acr", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "01ca7824-0953-4d5f-acf5-99fbf0ef2eb6", + "clientId": "ios-app", + "name": "iOS app", + "description": "Client for opex iOS app", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "600", + "request.object.signature.alg": "any", + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "exclude.issuer.from.auth.response": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "require.pushed.authorization.requests": "false", + "request.object.encryption.enc": "any", + "client.session.max.lifespan": "604800", + "client.secret.creation.time": "1747492982", + "request.object.encryption.alg": "any", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "exclude.session.state.from.auth.response": "false", + "client.use.lightweight.access.token.enabled": "false", + "request.object.required": "not required", + "tls.client.certificate.bound.access.tokens": "false", + "acr.loa.map": "{}", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "trust", + "basic", + "role_permission_attribute" + ], + "optionalClientScopes": [] + }, + { + "id": "fb5f91c4-42fa-4769-b45d-febef22b4976", + "clientId": "opex-admin", + "name": "${client_opex-admin}", + "description": "", + "rootUrl": "${authBaseUrl}", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "access.token.lifespan": "86400", + "client.secret.creation.time": "1745840695", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "false", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "1543d277-d4f0-4498-89a3-8fe488eb8d87", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "4ca1806c-d230-40e4-8aeb-7be48ec9a1ef", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "ec94a143-2a78-4275-a4fc-aa246c1c6628", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "f7258787-d1d7-4a41-82c6-8e9e00008b27", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + } + ], + "defaultClientScopes": [ + "service_account", + "trust", + "web-origins", + "acr", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Default Resource", + "type": "urn:opex-admin:resources:default", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [ + "/*" + ] + } + ], + "policies": [ + { + "name": "Default Policy", + "description": "A policy that grants access only for users within this realm", + "type": "js", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" + } + }, + { + "name": "Default Permission", + "description": "A permission that applies to the default resource type", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "defaultResourceType": "urn:opex-admin:resources:default", + "applyPolicies": "[\"Default Policy\"]" + } + } + ], + "scopes": [], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "clientId": "opex-api-key", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "access.token.lifespan": "43200", + "client.secret.creation.time": "1749460384", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "trust", + "Forced_Roles", + "basic", + "role_permission_attribute" + ], + "optionalClientScopes": [ + "offline_access" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Default Resource", + "type": "urn:opex-api-key:resources:default", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [ + "/*" + ] + } + ], + "policies": [ + { + "name": "Default Policy", + "description": "A policy that grants access only for users within this realm", + "type": "js", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" + } + }, + { + "name": "Default Permission", + "description": "A permission that applies to the default resource type", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "defaultResourceType": "urn:opex-api-key:resources:default", + "applyPolicies": "[\"Default Policy\"]" + } + } + ], + "scopes": [], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "6a4bfbd0-576d-4778-af56-56f876647355", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "14ccedf9-e008-4fe5-901a-98663b937712", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "f82db182-9fba-4ffe-8138-65060d603dba", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "0f723243-8327-4640-861a-ee940cd18de9", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": false, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "client.resource.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "Client", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "view" + }, + { + "name": "map-roles-client-scope" + }, + { + "name": "configure" + }, + { + "name": "map-roles" + }, + { + "name": "manage" + }, + { + "name": "token-exchange" + }, + { + "name": "map-roles-composite" + } + ] + }, + { + "name": "client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "Client", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "view" + }, + { + "name": "map-roles-client-scope" + }, + { + "name": "configure" + }, + { + "name": "map-roles" + }, + { + "name": "manage" + }, + { + "name": "token-exchange" + }, + { + "name": "map-roles-composite" + } + ] + }, + { + "name": "Users", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "user-impersonated" + }, + { + "name": "manage-group-membership" + }, + { + "name": "view" + }, + { + "name": "impersonate" + }, + { + "name": "map-roles" + }, + { + "name": "manage" + } + ] + }, + { + "name": "client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "Client", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "view" + }, + { + "name": "map-roles-client-scope" + }, + { + "name": "configure" + }, + { + "name": "map-roles" + }, + { + "name": "manage" + }, + { + "name": "token-exchange" + }, + { + "name": "map-roles-composite" + } + ] + }, + { + "name": "client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "Client", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "view" + }, + { + "name": "map-roles-client-scope" + }, + { + "name": "configure" + }, + { + "name": "map-roles" + }, + { + "name": "manage" + }, + { + "name": "token-exchange" + }, + { + "name": "map-roles-composite" + } + ] + }, + { + "name": "idp.resource.6456448e-2415-49ad-bf95-1b5176557862", + "type": "IdentityProvider", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "token-exchange" + } + ] + } + ], + "policies": [ + { + "name": "account-console-client-impersonate", + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "clients": "[\"opex-admin\",\"account-console\"]" + } + }, + { + "name": "opex-api-exchange", + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "clients": "[\"opex-admin\"]" + } + }, + { + "name": "manage.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.13d76feb-d762-4409-bb84-7a75bc395a61\"]", + "scopes": "[\"manage\"]" + } + }, + { + "name": "configure.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.13d76feb-d762-4409-bb84-7a75bc395a61\"]", + "scopes": "[\"configure\"]" + } + }, + { + "name": "view.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.13d76feb-d762-4409-bb84-7a75bc395a61\"]", + "scopes": "[\"view\"]" + } + }, + { + "name": "map-roles.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.13d76feb-d762-4409-bb84-7a75bc395a61\"]", + "scopes": "[\"map-roles\"]" + } + }, + { + "name": "map-roles-client-scope.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.13d76feb-d762-4409-bb84-7a75bc395a61\"]", + "scopes": "[\"map-roles-client-scope\"]" + } + }, + { + "name": "map-roles-composite.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.13d76feb-d762-4409-bb84-7a75bc395a61\"]", + "scopes": "[\"map-roles-composite\"]" + } + }, + { + "name": "token-exchange.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.13d76feb-d762-4409-bb84-7a75bc395a61\"]", + "scopes": "[\"token-exchange\"]" + } + }, + { + "name": "token-exchange.permission.idp.6456448e-2415-49ad-bf95-1b5176557862", + "description": "", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"idp.resource.6456448e-2415-49ad-bf95-1b5176557862\"]", + "scopes": "[\"token-exchange\"]", + "applyPolicies": "[\"opex-api-exchange\"]" + } + }, + { + "name": "manage.permission.client.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00\"]", + "scopes": "[\"manage\"]" + } + }, + { + "name": "configure.permission.client.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00\"]", + "scopes": "[\"configure\"]" + } + }, + { + "name": "view.permission.client.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00\"]", + "scopes": "[\"view\"]" + } + }, + { + "name": "map-roles.permission.client.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00\"]", + "scopes": "[\"map-roles\"]" + } + }, + { + "name": "map-roles-client-scope.permission.client.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00\"]", + "scopes": "[\"map-roles-client-scope\"]" + } + }, + { + "name": "map-roles-composite.permission.client.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00\"]", + "scopes": "[\"map-roles-composite\"]" + } + }, + { + "name": "token-exchange.permission.client.ceabb7ca-b063-4755-90fb-8de2cc7e5e00", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.ceabb7ca-b063-4755-90fb-8de2cc7e5e00\"]", + "scopes": "[\"token-exchange\"]" + } + }, + { + "name": "manage.permission.users", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Users\"]", + "scopes": "[\"manage\"]" + } + }, + { + "name": "view.permission.users", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Users\"]", + "scopes": "[\"view\"]" + } + }, + { + "name": "map-roles.permission.users", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Users\"]", + "scopes": "[\"map-roles\"]" + } + }, + { + "name": "manage-group-membership.permission.users", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Users\"]", + "scopes": "[\"manage-group-membership\"]" + } + }, + { + "name": "admin-impersonating.permission.users", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Users\"]", + "scopes": "[\"impersonate\"]", + "applyPolicies": "[\"account-console-client-impersonate\"]" + } + }, + { + "name": "user-impersonated.permission.users", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Users\"]", + "scopes": "[\"user-impersonated\"]" + } + }, + { + "name": "view.permission.client.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", + "scopes": "[\"view\"]" + } + }, + { + "name": "manage.permission.client.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", + "scopes": "[\"manage\"]" + } + }, + { + "name": "configure.permission.client.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", + "scopes": "[\"configure\"]" + } + }, + { + "name": "map-roles.permission.client.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", + "scopes": "[\"map-roles\"]" + } + }, + { + "name": "map-roles-client-scope.permission.client.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", + "scopes": "[\"map-roles-client-scope\"]" + } + }, + { + "name": "map-roles-composite.permission.client.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", + "scopes": "[\"map-roles-composite\"]" + } + }, + { + "name": "token-exchange.permission.client.fb5f91c4-42fa-4769-b45d-febef22b4976", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", + "scopes": "[\"token-exchange\"]" + } + }, + { + "name": "manage.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"manage\"]" + } + }, + { + "name": "configure.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"configure\"]" + } + }, + { + "name": "view.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"view\"]" + } + }, + { + "name": "map-roles.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"map-roles\"]" + } + }, + { + "name": "map-roles-client-scope.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"map-roles-client-scope\"]" + } + }, + { + "name": "map-roles-composite.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"map-roles-composite\"]" + } + }, + { + "name": "token-exchange.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"token-exchange\"]", + "applyPolicies": "[\"opex-api-exchange\"]" + } + } + ], + "scopes": [ + { + "name": "manage" + }, + { + "name": "view" + }, + { + "name": "map-roles" + }, + { + "name": "map-roles-client-scope" + }, + { + "name": "map-roles-composite" + }, + { + "name": "configure" + }, + { + "name": "token-exchange" + }, + { + "name": "impersonate" + }, + { + "name": "user-impersonated" + }, + { + "name": "manage-group-membership" + }, + { + "name": "map-role" + }, + { + "name": "map-role-client-scope" + }, + { + "name": "map-role-composite" + } + ], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "8e358d2f-b085-4243-8e6e-c175431e5eeb", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/opex/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/opex/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "saml.server.signature": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "backchannel.logout.session.required": "false", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "pkce.code.challenge.method": "S256", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "9cfca9ee-493d-4b5e-8170-2d364149de59", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "fd116873-8b00-4851-a88d-1a72575783ba", + "clientId": "web-app", + "name": "Web app", + "description": "Client for opex web app", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "3600", + "request.object.signature.alg": "any", + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "exclude.issuer.from.auth.response": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "require.pushed.authorization.requests": "false", + "request.object.encryption.enc": "any", + "client.session.max.lifespan": "604800", + "client.secret.creation.time": "1747492073", + "request.object.encryption.alg": "any", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "standard.token.exchange.enabled": "false", + "exclude.session.state.from.auth.response": "false", + "client.use.lightweight.access.token.enabled": "false", + "request.object.required": "not required", + "tls.client.certificate.bound.access.tokens": "false", + "acr.loa.map": "{}", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "trust", + "audience-opex-api-key", + "basic", + "role_permission_attribute" + ], + "optionalClientScopes": [] + } + ], + "clientScopes": [ + { + "id": "0cbd4466-de57-4fc9-81a7-f34f3cd2262a", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "4fbc5bd0-1bd0-4fda-89b7-b83492a8ac42", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "3bea689a-7d18-47df-a545-499ef0548698", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "d509437d-eb13-4322-9f76-d5d79a65ff20", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "05ec9110-1046-4784-ae97-d8bf86bcfc62", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "dd201a3e-1243-474d-af83-312a0130f2f3", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "ba8c9950-fd0b-4434-8be6-b58456d7b6d4", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "0a9ddd71-309c-40f0-8ea6-a0791070c6ed", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "fbf53bbd-1ad0-4bf8-8030-50f81696d8ee", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "423be2cd-42c0-462e-9030-18f9b28ff2d3", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "53eb9006-4b81-474a-8b60-80f775d54b63", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "4d8bc82a-eaeb-499e-8eb2-0f1dcbe91699", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "d3b25485-4042-419d-afff-cfd63a76e229", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "422cfa5a-f2f4-4f36-82df-91b47ae1ea50", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "3f2863c1-d98d-45b5-b08f-af9c4d9c10f8", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "c98c063d-eee4-41a0-9130-595afd709d1f", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "8dbed80a-d672-4185-8dda-4bba2a56ec83", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "5e5c690c-93cf-489d-a054-b109eab8911b", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "3b985202-af8a-42f1-ac5f-0966a404f5d7", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "6eafd1b3-7121-4919-ad1e-039fa58acc32", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "73cba925-8c31-443f-9601-b1514e6396c1", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "d1debc5e-632b-4c2e-863b-2f5b2b1572d5", + "name": "user-roles", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "d8aa4645-5f5b-41e7-b7e6-b12739ca0cca", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "dbb7860d-fba0-4c05-8b99-f2f9cdd3ffb4", + "name": "audience-opex-api-key", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "627885b9-1bb8-465a-9125-e9603ac1dcd5", + "name": "audience-opex-api-key", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "opex-api-key", + "id.token.claim": "false", + "lightweight.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "id": "51d49314-b511-43e0-9258-bfb873758a78", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "2b384cd0-9e85-4a87-8eeb-2b480b0587b7", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "e39a0ba3-b2df-4378-9628-4205d41a8ead", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "3fe8e716-16f7-48b1-beeb-e9c2807a9376", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "48c68e2a-5035-4ba8-8d17-d2b4922a1613", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + }, + { + "id": "f4b67796-04fa-49b5-846f-a5ab81587c55", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "18e141bf-dabe-4858-879c-dbc439cdead4", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "${samlRoleListScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "10cbe37f-0198-4d65-bc8a-bfe5ad8145d1", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "94624568-d736-46dc-83af-d1f29f25a25e", + "name": "realm-roles", + "description": "Shows user's assigned realm roles", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "02f5bef3-673f-4242-a6de-12eaaffe5d58", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "false", + "user.attribute": "foo", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "c658ae14-e96a-4745-b21b-2ed5c4c63f5f", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "959521bc-5ffd-465b-95f2-5b0c20d1909c", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "id": "07b8550c-b298-4cce-9ffb-900182575b76", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "569b3d44-4ecd-4768-a58c-70ff38f4b4fe", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "d4e253fb-7361-47cf-9d4a-86245686fdf2", + "name": "trust", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "2bafcd16-ff19-4f72-adb4-c1735793842d", + "name": "User roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "multivalued": "true", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String" + } + }, + { + "id": "aeca072b-0153-4ffc-b3de-bea60f4a7fd7", + "name": "Mobile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "mobile", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "mobile", + "jsonType.label": "String" + } + }, + { + "id": "b37f97ee-3a0a-498f-9535-f2afe2a881b9", + "name": "Email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "a163e6a9-3fbe-481d-b783-779acdf41436", + "name": "First name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "firstName", + "jsonType.label": "String" + } + }, + { + "id": "ba98367f-171f-490d-8fdf-65ab4cdf46f4", + "name": "Last name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "lastName", + "jsonType.label": "String" + } + }, + { + "id": "270e9cfc-cdbf-4fa1-8358-47e381abce45", + "name": "Username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "username", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "77c7e29d-1a22-4419-bbfb-4a62bb033449", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "94e1879d-b49e-4178-96e0-bf8d7f32c160", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "b3526ac1-10e2-4344-8621-9c5a0853e97a", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "d30270dc-baa6-455a-8ff6-ddccf8a78d86", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean", + "userinfo.token.claim": "true" + } + }, + { + "id": "f5b1684d-e479-4134-8578-457fa64717da", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "b5f64b6e-cf08-47d8-be89-67bb0789d93f", + "name": "role_permission_attribute", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "f9a8a8ff-93b8-4299-8fa0-7db51cc715bb", + "name": "Role permission attribute", + "protocol": "openid-connect", + "protocolMapper": "role-attributes-mapper", + "consentRequired": false, + "config": { + "claim.name": "permissions", + "jsonType.label": "String", + "attribute.name": "permissions" + } + } + ] + }, + { + "id": "60575b8d-b5ab-47c3-a2fa-b14e77d4f392", + "name": "Forced_Roles", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "a2ffe329-0ed7-4861-a6aa-e72a25b98770", + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "forced-role-mapper", + "consentRequired": false, + "config": { + "claim.name": "roles", + "jsonType.label": "String", + "key.name": "roles" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "email", + "trust", + "acr", + "basic", + "microprofile-jwt", + "web-origins", + "role_permission_attribute", + "role_list", + "realm-roles", + "audience-opex-api-key", + "user-roles", + "Forced_Roles" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": { + "host": "smtp.elasticemail.com", + "password": "${vault.smtppass}", + "from": "for.demo.purpose.only@opex.dev", + "auth": "true", + "port": "2525", + "user": "for.demo.purpose.only@opex.dev" + }, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [ + { + "alias": "google", + "internalId": "6456448e-2415-49ad-bf95-1b5176557862", + "providerId": "google", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "hideOnLogin": false, + "config": { + "syncMode": "LEGACY", + "clientSecret": "**********", + "clientId": "625813606110-er3v3sol4v206kdg40gf0ltqv08scgs2.apps.googleusercontent.com", + "autoLink": "true", + "defaultScope": "openid profile email" + } + } + ], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "365b2899-befe-4417-b89b-562650ec4446", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "76075388-2782-4656-a986-313493239a9f", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "3caaf57a-9cd7-48c1-b709-b40b887414f7", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper" + ] + } + }, + { + "id": "84305f42-4b6d-4b0a-ac7c-53e406e3ac63", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "c7c38a95-744f-4558-a403-9cf692fe1944", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "81c32244-7921-43e9-9356-a3469259b78c", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "trusted-hosts": [ + "https://opex.dev/bdemo/login" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "d09b2147-afea-4f7f-a49c-0aec7eee10de", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "41ffde1b-72a2-416f-87a7-94989e940dc0", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-property-mapper" + ] + } + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "9975d471-20ec-4099-8f08-52db9aaed738", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"mobile\",\"displayName\":\"Mobile\",\"validations\":{\"length\":{\"min\":\"11\",\"max\":\"15\"}},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"otpConfig\",\"displayName\":\"OTP Config\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "d67a940a-52e4-44a5-9f69-6ffdd67a188f", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "7fe566f1-60d8-49b7-89cc-30c31e00b86c", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + }, + { + "id": "48d40de3-6234-42e8-9449-f68f56abb54b", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "52ea1c5d-2a30-459f-b66a-249f298b32f8", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "a5158583-26f5-45da-8c7d-edfed5c20889", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account - Alternatives - 0", + "userSetupAllowed": false + } + ] + }, + { + "id": "580af056-728f-4784-95a4-6b4b5cbbfda3", + "alias": "Handle Existing Account - Alternatives - 0", + "description": "Subflow of Handle Existing Account with alternative executions", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "cb9a1006-177c-4ae9-8a13-a392cac637f9", + "alias": "Opex Direct Grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "15bc451f-81d6-4b45-abd3-0a742f8b87ca", + "alias": "Opex Registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Opex Registration registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "52d99dce-a3d9-4551-a230-424223ab4a61", + "alias": "Opex Registration registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "registration-opex-captcha-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 51, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "960bd7aa-8c01-4334-9460-238edb6c716a", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication - auth-otp-form - Conditional", + "userSetupAllowed": false + } + ] + }, + { + "id": "8ff718aa-4e39-48d2-aacf-fdd456c75a53", + "alias": "Verify Existing Account by Re-authentication - auth-otp-form - Conditional", + "description": "Flow to determine if the auth-otp-form authenticator should be used or not.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "011b0d5c-1da6-4519-b3bd-7779de83021b", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "084faf8d-bfa0-4f0d-b93b-027b54132537", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "2b945774-136a-45c8-b2dd-f023f441edc4", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "direct grant - direct-grant-validate-otp - Conditional", + "userSetupAllowed": false + } + ] + }, + { + "id": "caa8b7d7-0bf8-4f8c-ae53-a25bbb52ad0a", + "alias": "direct grant - direct-grant-validate-otp - Conditional", + "description": "Flow to determine if the direct-grant-validate-otp authenticator should be used or not.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "1ab8d8fd-db75-4cf6-b105-813cce023ece", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "934881e3-32ff-4050-933e-d281935e4b8f", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "first broker login - Alternatives - 0", + "userSetupAllowed": false + } + ] + }, + { + "id": "d8feb86c-0c21-4570-9b7b-ad42223ceec5", + "alias": "first broker login - Alternatives - 0", + "description": "Subflow of first broker login with alternative executions", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "3a39fbcd-8450-428c-b4cf-e9e1fa46c395", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "forms - auth-otp-form - Conditional", + "userSetupAllowed": false + } + ] + }, + { + "id": "c5ee3815-9c15-4ee6-a266-c693f1e2e655", + "alias": "forms - auth-otp-form - Conditional", + "description": "Flow to determine if the auth-otp-form authenticator should be used or not.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d3363796-a3f2-458a-9510-cd0daaedbb18", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "619ca7f4-f21d-450c-ad5a-043866073779", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "71edeb90-7547-41e3-a395-547bfbc3ae51", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "reset credentials - reset-otp - Conditional", + "userSetupAllowed": false + } + ] + }, + { + "id": "2e577b18-4986-4556-8396-737eff6dcf40", + "alias": "reset credentials - reset-otp - Conditional", + "description": "Flow to determine if the reset-otp authenticator should be used or not.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "631176c8-0237-4488-9d3d-1662da821b3b", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "a4f4b792-ec6b-4fb1-89ec-8b6ae589fff6", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "9b1f556c-8d73-4bdf-8341-7cf7cf858ad7", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "Opex Registration", + "directGrantFlow": "Opex Direct Grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.1.5", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/.gitignore b/auth-gateway/keycloak-setup/spi/.gitignore new file mode 100644 index 000000000..5ff6309b7 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/pom.xml b/auth-gateway/keycloak-setup/spi/pom.xml new file mode 100644 index 000000000..426d13566 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + co.nilin.opex.keycloak + spi + 1.0 + + + 21 + 21 + UTF-8 + + + + + org.keycloak + keycloak-core + 26.0.0 + + + org.keycloak + keycloak-server-spi + 26.0.0 + + + org.keycloak + keycloak-server-spi-private + 26.0.0 + + + org.keycloak + keycloak-services + 26.0.0 + + + + \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/BootstrapTokenGrantAuthenticator.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/BootstrapTokenGrantAuthenticator.java new file mode 100644 index 000000000..f81eac098 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/BootstrapTokenGrantAuthenticator.java @@ -0,0 +1,79 @@ +package co.nilin.opex.keycloak.spi; + +import org.keycloak.TokenVerifier; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.*; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.MediaType; +import org.keycloak.representations.AccessToken; + +import java.util.*; + +public class BootstrapTokenGrantAuthenticator implements Authenticator { + + @Override + public void authenticate(AuthenticationFlowContext context) { + MultivaluedMap params = context.getHttpRequest().getDecodedFormParameters(); + String bootstrapTokenString = params.getFirst("bootstrap_token"); + + if (bootstrapTokenString == null || bootstrapTokenString.isEmpty()) { + + System.out.println("No bootstrap token found, skipping to next authenticator."); + + String username = context.getHttpRequest().getDecodedFormParameters().getFirst("username"); + if (username != null) { + UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username); + if (user != null) { + context.setUser(user); // Attach the user so the next step (Password) knows who to check + } + } + context.attempted(); + return; + } + try { + // Parse the JWT to get the user ID (the 'sub' claim) + AccessToken token = TokenVerifier.create(bootstrapTokenString, AccessToken.class).getToken(); + String userId = token.getSubject(); + + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + + // Find the actual user from the database + UserModel user = session.users().getUserById(realm, userId); + + if (user == null || !user.isEnabled()) { + sendError(context, "invalid_grant"); + return; + } + + // IMPORTANT: Identify the user and tell Keycloak this step is finished successfully + context.setUser(user); + context.success(); + + } catch (Exception e) { + // This happens if the JWT is malformed or expired + sendError(context, "invalid_grant"); + } + } + + private void sendError(AuthenticationFlowContext context, String errorCode) { + Map errorEntity = new HashMap<>(); + errorEntity.put("error", errorCode); + + Response response = Response.status(Response.Status.BAD_REQUEST) + .entity(errorEntity) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); + + context.failure(AuthenticationFlowError.UNKNOWN_USER, response); + } + + @Override public void action(AuthenticationFlowContext context) {} + @Override public boolean requiresUser() { return false; } + @Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; } + @Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} + @Override public void close() {} +} \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/ForcedRoleProtocolMapper.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/ForcedRoleProtocolMapper.java new file mode 100644 index 000000000..f858bd900 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/ForcedRoleProtocolMapper.java @@ -0,0 +1,73 @@ +package co.nilin.opex.keycloak.spi; + +import org.keycloak.models.*; +import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.AccessToken; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +public class ForcedRoleProtocolMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper { + + private static final String PROVIDER_ID = "forced-role-mapper"; + private static final String ROLE_ATTRIBUTES_CLAIM = "forced_role"; + + public static final String KEY_NAME = "key.name"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayCategory() { + return "Forced Role"; + } + + @Override + public String getDisplayType() { + return "Forced Role Mapper"; + } + + @Override + public String getHelpText() { + return "Forces the addition of roles to the token."; + } + + @Override + public List getConfigProperties() { + List configProperties = new ArrayList<>(); + OIDCAttributeMapperHelper.addTokenClaimNameConfig(configProperties); + OIDCAttributeMapperHelper.addJsonTypeConfig(configProperties); + + ProviderConfigProperty attributeName = new ProviderConfigProperty(); + attributeName.setName(KEY_NAME); + attributeName.setLabel("Key Name"); + attributeName.setType(ProviderConfigProperty.STRING_TYPE); + attributeName.setHelpText("The name of the role to include in the token."); + configProperties.add(attributeName); + return configProperties; + } + + @Override + public AccessToken transformAccessToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel user, ClientSessionContext clientSessionCtx) { + String claimName = mappingModel.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME); + var finalList = new HashSet<>(); + + user.getUser().getRealmRoleMappingsStream().forEach(role -> { + finalList.add(role.getName()); + role.getCompositesStream().forEach(r -> finalList.add(r.getName())); + }); + + if (!finalList.isEmpty()) { + token.getOtherClaims().put(claimName != null && !claimName.isEmpty() ? claimName : ROLE_ATTRIBUTES_CLAIM, finalList); + } + + return token; + } + +} diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/RoleAttributesProtocolMapper.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/RoleAttributesProtocolMapper.java new file mode 100644 index 000000000..392f8df98 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/RoleAttributesProtocolMapper.java @@ -0,0 +1,91 @@ +package co.nilin.opex.keycloak.spi; + +import org.keycloak.models.*; +import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.AccessToken; + +import java.util.List; +import java.util.ArrayList; +import java.util.stream.Collectors; + +public class RoleAttributesProtocolMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper { + + private static final String PROVIDER_ID = "role-attributes-mapper"; + private static final String ROLE_ATTRIBUTES_CLAIM = "role_attributes"; + + public static final String ATTRIBUTE_NAME = "attribute.name"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayCategory() { + return "Role Attributes"; + } + + @Override + public String getDisplayType() { + return "Role Attributes Mapper"; + } + + @Override + public String getHelpText() { + return "Adds attributes of user's roles to the token."; + } + + @Override + public List getConfigProperties() { + List configProperties = new ArrayList<>(); + OIDCAttributeMapperHelper.addTokenClaimNameConfig(configProperties); + OIDCAttributeMapperHelper.addJsonTypeConfig(configProperties); + + ProviderConfigProperty attributeName = new ProviderConfigProperty(); + attributeName.setName(ATTRIBUTE_NAME); + attributeName.setLabel("Attribute Name"); + attributeName.setType(ProviderConfigProperty.STRING_TYPE); + attributeName.setHelpText("The name of the role attribute to include in the token."); + configProperties.add(attributeName); + return configProperties; + } + + @Override + public AccessToken transformAccessToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel user, ClientSessionContext clientSessionCtx) { + String attributeNameToInclude = mappingModel.getConfig().get(ATTRIBUTE_NAME); + String claimName = mappingModel.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME); + var finalList = new ArrayList<>(); + + List userRoles = user.getUser().getRealmRoleMappingsStream().collect(Collectors.toList()); + for (RoleModel role : userRoles) { + extract(role, attributeNameToInclude, finalList); + var compositeRoles = role.getCompositesStream().collect(Collectors.toList()); + compositeRoles.forEach(r -> extract(r, attributeNameToInclude, finalList)); + } + + if (!finalList.isEmpty()) { + token.getOtherClaims().put(claimName != null && !claimName.isEmpty() ? claimName : ROLE_ATTRIBUTES_CLAIM, finalList); + } + return token; + } + + private void extract(RoleModel role, String attributeNameToInclude, ArrayList list) { + var attributes = role.getAttributes(); + if (attributes.containsKey(attributeNameToInclude)) { + var att = attributes.get(attributeNameToInclude); + if (att.isEmpty()) + return; + + var value = att.get(0); + var splt = value.split(","); + for (var v : splt) { + if (!v.isBlank()) + list.add(v); + } + } + } + +} diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/BootstrapTokenGrantProviderFactory.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/BootstrapTokenGrantProviderFactory.java new file mode 100644 index 000000000..7ff747e80 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/BootstrapTokenGrantProviderFactory.java @@ -0,0 +1,86 @@ +package co.nilin.opex.keycloak.spi.endpoints; + +import co.nilin.opex.keycloak.spi.BootstrapTokenGrantAuthenticator; +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import java.util.Collections; +import java.util.List; + +public class BootstrapTokenGrantProviderFactory implements AuthenticatorFactory { + + // 1. Change PROVIDER_ID to a simple name. + // Do not use the URN here, as we are now intercepting the standard password grant. + public static final String PROVIDER_ID = "bootstrap-token-grant"; + + private static final BootstrapTokenGrantAuthenticator SINGLETON = new BootstrapTokenGrantAuthenticator(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + // This is the name you will see in the Keycloak Admin Console 'Add Step' list + return "Opex Bootstrap Interceptor"; + } + + @Override + public String getReferenceCategory() { + // Keep this as "grant" so it appears in the Direct Grant flow options + return "grant"; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED + }; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public String getHelpText() { + return "Intercepts standard password grant to exchange a bootstrap_token for full tokens"; + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + + @Override + public int order() { + return 0; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } +} \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/PasswordEndpointResourceProvider.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/PasswordEndpointResourceProvider.java new file mode 100644 index 000000000..20e037d05 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/PasswordEndpointResourceProvider.java @@ -0,0 +1,59 @@ +package co.nilin.opex.keycloak.spi.endpoints; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.services.resource.RealmResourceProvider; + +import java.util.Map; + +public class PasswordEndpointResourceProvider implements RealmResourceProvider { + + private final KeycloakSession session; + + public PasswordEndpointResourceProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public Object getResource() { + return this; + } + + @Override + public void close() { + + } + + @POST + @Path("/validate") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response validatePassword(Map payload) { + var userId = payload.get("userId"); + var password = payload.get("password"); + if (userId == null || password == null) + return Response.status(Response.Status.BAD_REQUEST).build(); + + var realm = session.getContext().getRealm(); + var user = session.users().getUserById(realm, userId); + if (user == null) + return Response.status(Response.Status.NOT_FOUND).build(); + + var input = new UserCredentialModel(); + input.setType(PasswordCredentialModel.TYPE); + input.setValue(password); + + var isValid = user.credentialManager().isValid(input); + if (isValid) + return Response.ok(Map.of("valid",true)).build(); + else + return Response.status(Response.Status.UNAUTHORIZED).build(); + } +} \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/PasswordEndpointResourceProviderFactory.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/PasswordEndpointResourceProviderFactory.java new file mode 100644 index 000000000..0e882258e --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/PasswordEndpointResourceProviderFactory.java @@ -0,0 +1,37 @@ +package co.nilin.opex.keycloak.spi.endpoints; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class PasswordEndpointResourceProviderFactory implements RealmResourceProviderFactory { + + public static final String ID = "password"; + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new PasswordEndpointResourceProvider(session); + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return ID; + } +} diff --git a/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 000000000..1dd794fa9 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +co.nilin.opex.keycloak.spi.endpoints.BootstrapTokenGrantProviderFactory diff --git a/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper new file mode 100644 index 000000000..842ee10be --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -0,0 +1,2 @@ +co.nilin.opex.keycloak.spi.RoleAttributesProtocolMapper +co.nilin.opex.keycloak.spi.ForcedRoleProtocolMapper \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 000000000..8f62c8527 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1 @@ +co.nilin.opex.keycloak.spi.endpoints.PasswordEndpointResourceProviderFactory \ No newline at end of file diff --git a/auth-gateway/pom.xml b/auth-gateway/pom.xml new file mode 100644 index 000000000..10792e72b --- /dev/null +++ b/auth-gateway/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + + + core + co.nilin.opex + 1.0.1-beta.7 + + + co.nilin.opex.auth + auth-gateway + auth-gateway + pom + + + 21 + 21 + 21 + 2.1.0 + 3.2.3 + 2023.0.0 + + + + auth-gateway-app + keycloak-setup/spi + + + + + org.springframework.boot + spring-boot-starter-test + + + co.nilin.opex + common + + + org.zalando + logbook-spring-boot-webflux-autoconfigure + 3.9.0 + + + org.keycloak + keycloak-core + 26.0.0 + compile + + + org.keycloak + keycloak-server-spi + 26.0.0 + compile + + + org.keycloak + keycloak-server-spi-private + 26.0.0 + compile + + + org.keycloak + keycloak-services + 26.0.0 + compile + + + org.keycloak + keycloak-services + 26.0.0 + compile + + + org.keycloak + keycloak-server-spi-private + 26.0.0 + compile + + + + + + + co.nilin.opex.api.core + api-core + ${project.version} + + + co.nilin.opex.api.ports.proxy + api-proxy-rest + ${project.version} + + + co.nilin.opex.api.ports.binance + api-binance-rest + ${project.version} + + + co.nilin.opex.api.ports.postgres + api-persister-postgres + ${project.version} + + + co.nilin.opex.utility + error-handler + ${error-hanlder.version} + + + co.nilin.opex.utility + interceptors + ${interceptor.version} + + + co.nilin.opex.utility + preferences + ${preferences.version} + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/Dockerfile b/bc-gateway/bc-gateway-app/Dockerfile index 2ad5a954a..29f0c2703 100644 --- a/bc-gateway/bc-gateway-app/Dockerfile +++ b/bc-gateway/bc-gateway-app/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:17 +FROM eclipse-temurin:17-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] diff --git a/bc-gateway/bc-gateway-app/pom.xml b/bc-gateway/bc-gateway-app/pom.xml index 86f6888d3..0046a178b 100644 --- a/bc-gateway/bc-gateway-app/pom.xml +++ b/bc-gateway/bc-gateway-app/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -112,6 +112,10 @@ co.nilin.opex.bcgateway.ports.walletproxy bc-gateway-wallet-proxy + + co.nilin.opex.bcgateway.ports.omniwallet + bc-gateway-omniwallet-proxy + co.nilin.opex.bcgateway.ports.authproxy bc-gateway-auth-proxy @@ -120,10 +124,6 @@ co.nilin.opex.bcgateway.ports.kafka.listener bc-gateway-eventlistener-kafka - - co.nilin.opex.utility - preferences - io.springfox springfox-boot-starter @@ -138,6 +138,14 @@ micrometer-registry-prometheus runtime + + co.nilin.opex.utility + interceptors + + + co.nilin.opex.utility + error-handler + diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt index 67e51c50a..6c326f929 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt @@ -6,24 +6,33 @@ import co.nilin.opex.bcgateway.core.api.InfoService import co.nilin.opex.bcgateway.core.service.AssignAddressServiceImpl import co.nilin.opex.bcgateway.core.service.InfoServiceImpl import co.nilin.opex.bcgateway.core.spi.AssignedAddressHandler -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler +import co.nilin.opex.bcgateway.core.spi.ChainLoader import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler import co.nilin.opex.bcgateway.ports.kafka.listener.consumer.AdminEventKafkaListener import co.nilin.opex.bcgateway.ports.kafka.listener.spi.AdminEventListener +import co.nilin.opex.bcgateway.ports.postgres.impl.CurrencyHandlerImplV2 import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.io.ResourceLoader +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.util.* + @Configuration -class AppConfig { +class AppConfig(private val resourceLoader: ResourceLoader) { @Bean fun assignAddressService( - currencyHandler: CurrencyHandler, + currencyHandler: CurrencyHandlerImplV2, assignedAddressHandler: AssignedAddressHandler, - reservedAddressHandler: ReservedAddressHandler + reservedAddressHandler: ReservedAddressHandler, + chainLoader: ChainLoader ): AssignAddressService { - return AssignAddressServiceImpl(currencyHandler, assignedAddressHandler, reservedAddressHandler) + return AssignAddressServiceImpl(currencyHandler, assignedAddressHandler, reservedAddressHandler, chainLoader) } @Bean @@ -38,4 +47,6 @@ class AppConfig { ) { adminKafkaEventListener.addEventListener(adminEventListener) } + + } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/InitializeService.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/InitializeService.kt index 58879f017..acb9d51dd 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/InitializeService.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/InitializeService.kt @@ -1,79 +1,20 @@ package co.nilin.opex.bcgateway.app.config -import co.nilin.opex.bcgateway.ports.postgres.dao.* -import co.nilin.opex.bcgateway.ports.postgres.model.AddressTypeModel -import co.nilin.opex.bcgateway.ports.postgres.model.ChainAddressTypeModel -import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyImplementationModel -import co.nilin.opex.utility.preferences.AddressType -import co.nilin.opex.utility.preferences.Chain -import co.nilin.opex.utility.preferences.Currency -import co.nilin.opex.utility.preferences.Preferences -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.runBlocking -import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.DependsOn +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Component import javax.annotation.PostConstruct @Component @DependsOn("postgresConfig") -class InitializeService( - private val addressTypeRepository: AddressTypeRepository, - private val chainRepository: ChainRepository, - private val chainAddressTypeRepository: ChainAddressTypeRepository, - private val currencyRepository: CurrencyRepository, - private val currencyImplementationRepository: CurrencyImplementationRepository, -) { - @Autowired - private lateinit var preferences: Preferences +@Profile("!otc") +class InitializeService { @PostConstruct fun init() = runBlocking { - addAddressTypes(preferences.addressTypes) - addChains(preferences.chains) - addCurrencies(preferences.currencies) - } - - private suspend fun addAddressTypes(data: List) = coroutineScope { - val items = data.mapIndexed { i, it -> - if (addressTypeRepository.existsById(i + 1L).awaitSingle()) null - else AddressTypeModel(null, it.addressType, it.addressRegex, null) - }.filterNotNull() - runCatching { addressTypeRepository.saveAll(items).collectList().awaitSingleOrNull() } - } - - private suspend fun addChains(data: List) = coroutineScope { - data.map { chainRepository.insert(it.name).awaitSingleOrNull() } - val items1 = data.map { - val addressTypeId = addressTypeRepository.findByType(it.addressType).awaitSingle().id!! - ChainAddressTypeModel(null, it.name, addressTypeId) - } - runCatching { chainAddressTypeRepository.saveAll(items1).collectList().awaitSingleOrNull() } - } - - private suspend fun addCurrencies(data: List) = coroutineScope { - coroutineScope { - data.forEach { - currencyRepository.insert(it.name, it.symbol).awaitSingleOrNull() - } - } - val items = data.flatMap { it.implementations.map { impl -> it to impl } }.map { (currency, impl) -> - CurrencyImplementationModel( - null, - currency.symbol, - impl.symbol.takeUnless { it.isEmpty() } ?: currency.symbol, - impl.chain, - impl.token, - impl.tokenAddress, - impl.tokenName, - impl.withdrawEnabled, - impl.withdrawFee, - impl.withdrawMin, - impl.decimal - ) - } - runCatching { currencyImplementationRepository.saveAll(items).collectList().awaitSingleOrNull() } + // addAddressTypes() + // addChains() + // addCurrencies() } } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt index ca05ba715..d5b553971 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt @@ -1,15 +1,16 @@ package co.nilin.opex.bcgateway.app.config -import co.nilin.opex.bcgateway.app.utils.hasRole -import co.nilin.opex.bcgateway.app.utils.hasRole +import co.nilin.opex.bcgateway.app.utils.AudienceValidator import co.nilin.opex.bcgateway.app.utils.hasRoleAndLevel -import org.springframework.beans.factory.annotation.Qualifier +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.Profile import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +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.web.server.SecurityWebFilterChain @@ -19,27 +20,43 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean @Profile("!otc") fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { http.csrf().disable() - .authorizeExchange() - .pathMatchers("/actuator/**").permitAll() - .pathMatchers("/swagger-ui/**").permitAll() - .pathMatchers("/swagger-resources/**").permitAll() - .pathMatchers("/wallet-sync/**").permitAll() - .pathMatchers("/currency/**").permitAll() - .pathMatchers("/filter/**").hasAuthority("SCOPE_trust") - .pathMatchers("/admin/**").hasRole("SCOPE_trust", "admin_system") - .pathMatchers("/v1/address/**").permitAll() - .pathMatchers("/deposit/**").permitAll() - .pathMatchers("/addresses/**").hasRole("SCOPE_trust", "admin_system") - .anyExchange().authenticated() - .and() - .oauth2ResourceServer() - .jwt() + .authorizeExchange() + .pathMatchers("/actuator/**").permitAll() + .pathMatchers("/swagger-ui/**").permitAll() + .pathMatchers("/swagger-resources/**").permitAll() + .pathMatchers("/wallet-sync/**").permitAll() + .pathMatchers("/currency/**").permitAll() + .pathMatchers("/filter/**").authenticated() + .pathMatchers("/admin/**").hasAuthority("ROLE_admin") + .pathMatchers("/v1/address/assign").permitAll() //TODO Permission Required ??? + .pathMatchers(HttpMethod.PUT, "/v1/address").hasAuthority("ROLE_admin") + .pathMatchers("/deposit/**").permitAll() + .pathMatchers("/addresses/**").hasAuthority("ROLE_admin") + .pathMatchers("/scanner/**").permitAll() + .pathMatchers("/crypto-currency/**").permitAll() + .pathMatchers("/currency/**").permitAll() + //otc + .pathMatchers(HttpMethod.PUT, "/v1/address").hasAuthority("ROLE_admin") + .pathMatchers("/admin/**").hasAuthority("ROLE_admin") + .pathMatchers("/wallet-sync/**").hasAuthority("ROLE_system") + .pathMatchers("/crypto-currency/chain").hasAuthority("ROLE_admin") + .pathMatchers("/crypto-currency/**").hasAuthority("ROLE_system") + .pathMatchers("/omni-balance/bc/**").hasAuthority("ROLE_admin") + .pathMatchers("/actuator/**").permitAll() + .pathMatchers("/scanner/**").permitAll() + .pathMatchers(HttpMethod.GET,"/v1/address/*/holder").permitAll() + .anyExchange().authenticated() + .and() + .oauth2ResourceServer() + .jwt { it.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } return http.build() } @@ -48,29 +65,60 @@ class SecurityConfig(private val webClient: WebClient) { @Profile("otc") fun otcSpringSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { http.csrf().disable() - .authorizeExchange() - .pathMatchers("/actuator/**").permitAll() - .pathMatchers("/swagger-ui/**").permitAll() - .pathMatchers(HttpMethod.PUT,"/v1/address").hasRoleAndLevel("Admin") - .pathMatchers("/swagger-resources/**").permitAll() - .pathMatchers("/admin/**").hasRoleAndLevel("Admin") - .pathMatchers("/wallet-sync/**").hasRoleAndLevel("System") - .pathMatchers("/currency/chains").hasRoleAndLevel("user") - .pathMatchers("/currency/**").hasRoleAndLevel("System") - .anyExchange().authenticated() - .and() - .oauth2ResourceServer() - .jwt() + .authorizeExchange() + .pathMatchers("/actuator/**").permitAll() + .pathMatchers("/swagger-ui/**").permitAll() + .pathMatchers(HttpMethod.PUT, "/v1/address").hasRoleAndLevel("Admin") + .pathMatchers("/swagger-resources/**").permitAll() + .pathMatchers("/admin/**").hasRoleAndLevel("Admin") + .pathMatchers("/wallet-sync/**").hasRoleAndLevel("System") + .pathMatchers("/crypto-currency/chain").hasRoleAndLevel("user") + .pathMatchers("/crypto-currency/**").hasRoleAndLevel("System") + .pathMatchers("/omni-balance/bc/**").hasRoleAndLevel("Admin") + .pathMatchers("/actuator/**").permitAll() + .pathMatchers("/scanner/**").permitAll() + .anyExchange().authenticated() + .and() + .oauth2ResourceServer() + .jwt() return http.build() } + + @Bean + @Profile("!otc") @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) - .webClient(webClient) - .build() + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } + @Bean + @Profile("otc") + @Throws(Exception::class) + fun otcReactiveJwtDecoder(): ReactiveJwtDecoder? { + return NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) + .build() + } + } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/WebClientConfig.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/WebClientConfig.kt index 8e4775d8b..4330d39fa 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/WebClientConfig.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/WebClientConfig.kt @@ -6,7 +6,6 @@ import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalance import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile -import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.web.reactive.function.client.ExchangeStrategies import org.springframework.web.reactive.function.client.WebClient import org.zalando.logbook.Logbook @@ -18,7 +17,10 @@ class WebClientConfig { @Bean @Profile("!otc") - fun loadBalancedWebClient(loadBalancerFactory: ReactiveLoadBalancer.Factory, logbook: Logbook): WebClient { + fun loadBalancedWebClient( + loadBalancerFactory: ReactiveLoadBalancer.Factory, + logbook: Logbook + ): WebClient { val client = HttpClient.create().doOnConnected { it.addHandlerLast(LogbookClientHandler(logbook)) } return WebClient.builder() //.clientConnector(ReactorClientHttpConnector(client)) diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressController.kt index e5510a1dc..09de48aae 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressController.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressController.kt @@ -1,13 +1,12 @@ package co.nilin.opex.bcgateway.app.controller import co.nilin.opex.bcgateway.core.api.AssignAddressService +import co.nilin.opex.bcgateway.core.model.AddressStatus import co.nilin.opex.bcgateway.core.model.AssignedAddress -import co.nilin.opex.bcgateway.core.model.Currency import co.nilin.opex.bcgateway.core.model.ReservedAddress import co.nilin.opex.bcgateway.core.spi.AddressTypeHandler import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler import co.nilin.opex.common.OpexError -import co.nilin.opex.utility.error.data.OpexException import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.springframework.http.codec.multipart.FilePart @@ -17,20 +16,20 @@ import org.springframework.web.bind.annotation.* import reactor.core.publisher.Mono import java.io.File import java.nio.charset.StandardCharsets -import java.time.ZoneId -import java.util.Collections -import java.util.stream.Collector -import java.util.stream.Collectors @RestController @RequestMapping("/v1/address") class AddressController( private val assignAddressService: AssignAddressService, private val reservedAddressHandler: ReservedAddressHandler, - private val addressTypeHandler: AddressTypeHandler + private val addressTypeHandler: AddressTypeHandler, ) { - data class AssignAddressRequest(val uuid: String, val currency: String, val chain: String) + data class AssignAddressRequest(val uuid: String, val currency: String, val gatewayUuid: String) data class AssignAddressResponse(val addresses: List) + data class AddressHolderResponse( + val uuid: String?, + val currentStatus: AddressStatus? + ) @PostMapping("/assign") suspend fun assignAddress( @@ -41,8 +40,8 @@ class AddressController( throw OpexError.Forbidden.exception() val assignedAddress = assignAddressService.assignAddress( assignAddressRequest.uuid, - Currency(assignAddressRequest.currency, assignAddressRequest.currency), - assignAddressRequest.chain + assignAddressRequest.currency, + assignAddressRequest.gatewayUuid ) return AssignAddressResponse(assignedAddress); @@ -55,6 +54,19 @@ class AddressController( // }.collect(Collectors.toList())) } + @GetMapping("/{address}/holder") + suspend fun getAddressHolder( + @PathVariable address: String, + @RequestParam(required = false) memo: String?, + @RequestParam(required = false) time: Long? + ): AddressHolderResponse { + val (holderUuid, currentStatus) = assignAddressService.findHolder(address, memo, time) + return AddressHolderResponse( + uuid = holderUuid, + currentStatus = currentStatus + ) + } + /** * (address, regex, address_type) */ @@ -73,4 +85,6 @@ class AddressController( // Do nothing in case of duplication (Or any constraint issue) runCatching { reservedAddressHandler.addReservedAddress(items) } } + + } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt index cf4b720d0..b774fe834 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt @@ -1,27 +1,37 @@ package co.nilin.opex.bcgateway.app.controller -import co.nilin.opex.bcgateway.app.dto.* +import co.nilin.opex.bcgateway.app.dto.AddAddressesRequest +import co.nilin.opex.bcgateway.app.dto.AddChainRequest +import co.nilin.opex.bcgateway.app.dto.AddressTypeRequest +import co.nilin.opex.bcgateway.app.dto.ChainResponse import co.nilin.opex.bcgateway.app.service.AdminService import co.nilin.opex.bcgateway.core.model.AddressType import co.nilin.opex.bcgateway.core.spi.AddressTypeHandler import co.nilin.opex.bcgateway.core.spi.ChainLoader -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler +import co.nilin.opex.bcgateway.ports.postgres.impl.CurrencyHandlerImplV2 import co.nilin.opex.common.OpexError import org.springframework.web.bind.annotation.* -import java.math.BigDecimal @RestController @RequestMapping("/admin") class AdminController( private val service: AdminService, private val chainLoader: ChainLoader, - private val currencyHandler: CurrencyHandler, - private val addressTypeHandler: AddressTypeHandler + private val currencyHandler: CurrencyHandlerImplV2, + private val addressTypeHandler: AddressTypeHandler, ) { @GetMapping("/chain") suspend fun getChains(): List { - return chainLoader.fetchAllChains().map { c -> ChainResponse(c.name, c.addressTypes.map { it.type }) } + return chainLoader.fetchAllChains() + .map { c -> + ChainResponse( + c.name, + c.addressTypes.map { it.type }.getOrNull(0), + c.externalChinScannerUrl, + c.addressTypes.map { it.addressRegex }.getOrNull(0) + ) + } } @PostMapping("/chain") @@ -43,55 +53,65 @@ class AdminController( service.addAddressType(body.name, body.addressRegex, body.memoRegex) } - @GetMapping("/token") - suspend fun getCurrencyImplementation(): List { - return currencyHandler.fetchAllImplementations() - .map { - TokenResponse( - it.currency.symbol, - it.chain.name, - it.token, - it.tokenAddress, - it.tokenName, - it.withdrawEnabled, - it.withdrawFee, - it.withdrawMin, - it.decimal - ) - } + @PostMapping("/addresses") + suspend fun addAddresses(@RequestBody body: AddAddressesRequest) { + service.addAddresses(body.addresses, body.memos, body.addressType) } - @PostMapping("/token") - suspend fun addCurrencyImplementation(@RequestBody body: TokenRequest): TokenResponse { - val ex = OpexError.InvalidRequestBody.exception() - with(body) { - if (currencySymbol.isNullOrEmpty() || chain.isNullOrEmpty()) throw ex - if (isToken && (tokenName.isNullOrEmpty() || tokenAddress.isNullOrEmpty())) throw ex - if (withdrawFee < BigDecimal.ZERO || minimumWithdraw < BigDecimal.ZERO || decimal < 0) throw ex - } + // shifted to crypto currency class! - return with(service.addToken(body)) { - TokenResponse( - currency.symbol, - chain.name, - token, - tokenAddress, - tokenName, - withdrawEnabled, - withdrawFee, - withdrawMin, - decimal - ) - } - } +// //todo filter tokens????? +// @GetMapping("/token") +// suspend fun getCurrencyImplementation(): List? { +// return currencyHandler.fetchCurrencyImpls()?.imps +// ?.map { +// TokenResponse( +// it.currencySymbol, +// it.chain, +// it.isToken!!, +// it.tokenAddress, +// it.tokenName, +// it.withdrawAllowed!!, +// it.withdrawFee!!, +// it.withdrawMin!!, +// it.decimal, +// it.isActive!! +// ) +// } +// } - @PutMapping("/token/{symbol}_{chain}/withdraw") - suspend fun changeWithdrawStatus( - @PathVariable symbol: String, - @PathVariable chain: String, - @RequestParam("enabled") status: Boolean - ) { - service.changeTokenWithdrawStatus(symbol, chain, status) - } +// @PostMapping("/token") +// suspend fun addCurrencyImplementation(@RequestBody body: TokenRequest): TokenResponse { +// val ex = OpexError.InvalidRequestBody.exception() +// with(body) { +// if (currencySymbol.isNullOrEmpty() || chain.isNullOrEmpty()) throw ex +// if (isToken && (tokenName.isNullOrEmpty() || tokenAddress.isNullOrEmpty())) throw ex +// if (withdrawFee < BigDecimal.ZERO || minimumWithdraw < BigDecimal.ZERO || decimal < 0) throw ex +// } +// +// return with(service.addToken(body)) { +// TokenResponse( +// currency, +// chain, +// token, +// tokenAddress, +// tokenName, +// withdrawEnabled, +// withdrawFee, +// withdrawMin, +// decimal, +// isActive +// ) +// } +// } + +// @PutMapping("/token/{symbol}_{chain}/withdraw") +// suspend fun changeWithdrawStatus( +// @PathVariable symbol: String, +// @PathVariable chain: String, +// @RequestParam("enabled") status: Boolean +// ) { +// service.changeTokenWithdrawStatus(symbol, chain, status) +// } } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CryptoCurrencyController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CryptoCurrencyController.kt new file mode 100644 index 000000000..7c2c6d07f --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CryptoCurrencyController.kt @@ -0,0 +1,117 @@ +package co.nilin.opex.bcgateway.app.controller + +import co.nilin.opex.bcgateway.app.dto.ChainResponse +import co.nilin.opex.bcgateway.app.dto.OnChainGatewayLocalizationResponse +import co.nilin.opex.bcgateway.core.model.CryptoCurrencyCommand +import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayLocalizationCommand +import co.nilin.opex.bcgateway.core.model.FetchGateways +import co.nilin.opex.bcgateway.core.model.WithdrawData +import co.nilin.opex.bcgateway.core.spi.ChainLoader +import co.nilin.opex.bcgateway.core.spi.CryptoCurrencyHandlerV2 +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.reactor.ReactorContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/crypto-currency") +class CryptoCurrencyController( + val cryptoCurrencyHandler: CryptoCurrencyHandlerV2, + private val chainLoader: ChainLoader +) { + + @PostMapping("/{currencySymbol}/gateway") + suspend fun addNewCurrencyGateway( + @PathVariable("currencySymbol") currencySymbol: String, + @RequestBody request: CryptoCurrencyCommand + ): CryptoCurrencyCommand? { + return cryptoCurrencyHandler.createOnChainGateway(request.apply { + this.currencySymbol = currencySymbol + }) + } + + + @PutMapping("/{currency}/gateway/{gatewayUuid}") + suspend fun updateCurrencyGateway( + @PathVariable("currency") currencySymbol: String, + @PathVariable("gatewayUuid") gatewayUuid: String, + @RequestBody request: CryptoCurrencyCommand + ): CryptoCurrencyCommand? { + return cryptoCurrencyHandler.updateOnChainGateway(request.apply { + this.currencySymbol = currencySymbol + this.gatewayUuid = gatewayUuid + }) + } + + + @DeleteMapping("/{currency}/gateway/{gatewayUuid}") + suspend fun deleteCurrencyGateway( + @PathVariable("currency") currencySymbol: String, + @PathVariable("gatewayUuid") gatewayUuid: String, + ): Void? { + return cryptoCurrencyHandler.deleteOnChainGateway( + gatewayUuid, currencySymbol + ) + } + + @GetMapping("/gateways") + suspend fun fetchGateways(@RequestParam("currency") currencySymbol: String? = null): List? { + return cryptoCurrencyHandler.fetchCurrencyOnChainGateways(FetchGateways(currencySymbol = currencySymbol)) + } + + @GetMapping("/{currency}/gateways") + suspend fun fetchCurrencyGateways(@PathVariable("currency") currencySymbol: String): List?? { + return cryptoCurrencyHandler.fetchCurrencyOnChainGateways(FetchGateways(currencySymbol = currencySymbol)) + } + + @GetMapping("/{currency}/gateway/{gatewayUuid}") + suspend fun fetchSpecificGateway( + @PathVariable("gatewayUuid") gatewayUuid: String, + @PathVariable("currency") currencySymbol: String + ): CryptoCurrencyCommand? { + return cryptoCurrencyHandler.fetchOnChainGateway(gatewayUuid, currencySymbol) + } + + @GetMapping("/chain") + suspend fun getChains(): List { + return chainLoader.fetchAllChains() + .map { c -> + ChainResponse( + c.name, + c.addressTypes.map { it.type }.getOrNull(0), + c.externalChinScannerUrl, + c.addressTypes.map { it.addressRegex }.getOrNull(0) + ) + } + } + + + @GetMapping("/{currency}/network/{network}/withdrawData") + suspend fun getFeeForCurrency(@PathVariable currency: String, @PathVariable network: String): WithdrawData { + return cryptoCurrencyHandler.getWithdrawData(currency, network) + } + + @PostMapping("/gateway/{uuid}/localization") + suspend fun saveGatewayLocalization( + @PathVariable("uuid") gatewayUuid: String, + @RequestBody localizations: List + ): OnChainGatewayLocalizationResponse { + val gatewayLocalizations = cryptoCurrencyHandler.saveOnChainGatewayLocalization(gatewayUuid, localizations) + return OnChainGatewayLocalizationResponse(gatewayUuid, gatewayLocalizations) + } + + @GetMapping("/gateway/{uuid}/localization") + suspend fun getGatewayLocalization( + @PathVariable("uuid") gatewayUuid: String + ): OnChainGatewayLocalizationResponse { + val gatewayLocalizations = cryptoCurrencyHandler.fetchOnChainGatewayLocalizations(gatewayUuid) + return OnChainGatewayLocalizationResponse(gatewayUuid, gatewayLocalizations) + } + + @DeleteMapping("/gateway/localization/{id}") + suspend fun deleteGatewayLocalization( + @PathVariable("id") id: Long, + ) { + cryptoCurrencyHandler.deleteOnChainGatewayLocalizations(id) + } + +} diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CurrencyController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CurrencyController.kt deleted file mode 100644 index 213f273a4..000000000 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CurrencyController.kt +++ /dev/null @@ -1,86 +0,0 @@ -package co.nilin.opex.bcgateway.app.controller - -import co.nilin.opex.bcgateway.app.dto.AddCurrencyRequest -import co.nilin.opex.bcgateway.core.model.CurrencyImplementation -import co.nilin.opex.bcgateway.core.model.CurrencyInfo -import co.nilin.opex.bcgateway.core.model.WithdrawData -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler -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.RequestParam -import org.springframework.web.bind.annotation.RestController -import java.math.BigDecimal - -@RestController -@RequestMapping("/currency") -class CurrencyController(val currencyHandler: CurrencyHandler) { - - @GetMapping("/{currency}") - suspend fun fetchCurrencyInfo(@PathVariable("currency") currency: String): CurrencyInfo { - return currencyHandler.fetchCurrencyInfo(currency) - } - - @PostMapping("/{currency}") - suspend fun addCurrencyInfo( - @PathVariable("currency") currencySymbol: String, - @RequestBody addCurrencyRequest: AddCurrencyRequest - ): CurrencyImplementation? { - addCurrencyRequest.currencySymbol = currencySymbol - with(addCurrencyRequest) { - return currencyHandler.addCurrencyImplementationV2( - this.currencySymbol, - implementationSymbol, - currencyName, - chain, - tokenName, - tokenAddress, - isToken!!, - withdrawFee, - minimumWithdraw, - isWithdrawEnabled!!, - decimal - ) - } - } - - @PutMapping("/{currency}") - suspend fun updateCurrencyInfo( - @PathVariable("currency") currencySymbol: String, - @RequestBody addCurrencyRequest: AddCurrencyRequest - ): CurrencyImplementation? { - addCurrencyRequest.currencySymbol = currencySymbol - with(addCurrencyRequest) { - return currencyHandler.updateCurrencyImplementation( - this.currencySymbol, - implementationSymbol, - currencyName, - newChain, - tokenName, - tokenAddress, - isToken!!, - withdrawFee, - minimumWithdraw, - isWithdrawEnabled!!, - decimal, - chain - ) - } - } - - @GetMapping("/chains") - suspend fun getNetworks(@RequestParam(required = false) currency: String?): List { - return if (currency != null) - currencyHandler.findImplementationsByCurrency(currency) - else - currencyHandler.fetchAllImplementations() - } - - @GetMapping("/{currency}/network/{network}/withdrawData") - suspend fun getFeeForCurrency(@PathVariable currency: String, @PathVariable network: String): WithdrawData { - return currencyHandler.getWithdrawData(currency, network) - } -} diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/OmniBCWalletController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/OmniBCWalletController.kt new file mode 100644 index 000000000..17bba3a55 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/OmniBCWalletController.kt @@ -0,0 +1,23 @@ +package co.nilin.opex.bcgateway.app.controller + +import co.nilin.opex.bcgateway.app.service.OmniBalanceService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/omni-balance/bc") +class OmniBCWalletController(private val omniBalanceService: OmniBalanceService) { + + @GetMapping("") + suspend fun getOmniBalance(): List? { + return omniBalanceService.fetchSystemBalance() + } + + @GetMapping("/{currency}") + suspend fun getOmniBalanceOfCurrency(@PathVariable currency: String): OmniBalanceService.OmniBalanceForCurrency { + return omniBalanceService.fetchSystemBalance(currency) + } + +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/ScannerController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/ScannerController.kt new file mode 100644 index 000000000..7c79f00cf --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/ScannerController.kt @@ -0,0 +1,46 @@ +package co.nilin.opex.bcgateway.app.controller + +import co.nilin.opex.bcgateway.core.api.WalletSyncService +import co.nilin.opex.bcgateway.core.model.Transfer +import co.nilin.opex.bcgateway.core.model.Wallet +import co.nilin.opex.common.utils.SignVerifier +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.core.io.ResourceLoader +import org.springframework.web.bind.annotation.* +import java.math.BigDecimal + +data class WebhookBody( + val txId: String, + val address: String, + val chain: String, + val amount: BigDecimal, + val memo: String?, + val isToken: Boolean, + val tokenAddress: String?, + val id: String?, + val date: Long +) + +@RestController +@RequestMapping("/scanner") +class ScannerController( + private val resourceLoader: ResourceLoader, + private val mapper: ObjectMapper, + private val service: WalletSyncService +) { + + private val logger = LoggerFactory.getLogger(ScannerController::class.java) + + @PostMapping("/webhook") + suspend fun webhook(@RequestHeader("X-Signature") sign: String, @RequestBody body: WebhookBody) { + val publicKeyRawStr = resourceLoader.getResource("classpath:scanner-public.pem").inputStream + .readAllBytes() + .toString(Charsets.UTF_8) + SignVerifier().verify("SHA256withRSA", publicKeyRawStr, mapper.writeValueAsString(body), sign) + logger.info("Webhook received for address ${body.address}, amount ${body.amount}") + service.sendTransfer(with(body) { Transfer(txId, Wallet(address, memo), isToken, amount, chain, tokenAddress) }) + } + + +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/WalletSyncController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/WalletSyncController.kt index 56904c44c..2c7d235ec 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/WalletSyncController.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/WalletSyncController.kt @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController +@Deprecated("ScannerController will be used") @RestController class WalletSyncController(private val chainHandler: ChainHandler, private val walletSyncService: WalletSyncService) { diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/AddAddressesRequest.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/AddAddressesRequest.kt new file mode 100644 index 000000000..90441cad8 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/AddAddressesRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.bcgateway.app.dto + +data class AddAddressesRequest( + val addresses: List, + val memos: List?, + val addressType: String, +) \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/AddCurrencyRequest.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/AddCurrencyRequest.kt index 26682e900..23293ef52 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/AddCurrencyRequest.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/AddCurrencyRequest.kt @@ -3,16 +3,16 @@ package co.nilin.opex.bcgateway.app.dto import java.math.BigDecimal data class AddCurrencyRequest( - var currencySymbol: String, - var implementationSymbol: String, - var currencyName:String, - var newChain: String?=null, - var tokenName: String?, - var tokenAddress: String?, - var isToken: Boolean? = false, - var withdrawFee: BigDecimal, - var minimumWithdraw: BigDecimal, - var isWithdrawEnabled: Boolean? = true, - var decimal: Int, - var chain: String + var currencySymbol: String, + var implementationSymbol: String, + var currencyName: String, + var newChain: String? = null, + var tokenName: String?, + var tokenAddress: String?, + var isToken: Boolean? = false, + var withdrawFee: BigDecimal, + var minimumWithdraw: BigDecimal, + var isWithdrawEnabled: Boolean? = true, + var decimal: Int, + var chain: String ) \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/ChainResponse.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/ChainResponse.kt index 3fc7dd3c6..fbb860f7b 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/ChainResponse.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/ChainResponse.kt @@ -2,5 +2,7 @@ package co.nilin.opex.bcgateway.app.dto data class ChainResponse( val name: String, - val addressTypes: List + val addressTypes: String?, + val externalChainScannerUrl: String? = null, + val addressRegex: String? = null ) diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/OnChainGatewayLocalizationResponse.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/OnChainGatewayLocalizationResponse.kt new file mode 100644 index 000000000..e4f63db18 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/OnChainGatewayLocalizationResponse.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.bcgateway.app.dto + +import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayLocalizationCommand + +data class OnChainGatewayLocalizationResponse( + val gatewayUuid: String, + val localizations: List +) diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/TokenResponse.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/TokenResponse.kt index d6cbbc9f5..d5bee76b6 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/TokenResponse.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/dto/TokenResponse.kt @@ -11,5 +11,6 @@ data class TokenResponse( val isWithdrawEnabled: Boolean, val withdrawFee: BigDecimal, val withdrawMin: BigDecimal, - val decimal: Int + val decimal: Int, + val isActive: Boolean ) \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/listener/AdminEventListenerImpl.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/listener/AdminEventListenerImpl.kt index 1bcf766c7..7787bbe31 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/listener/AdminEventListenerImpl.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/listener/AdminEventListenerImpl.kt @@ -1,10 +1,7 @@ package co.nilin.opex.bcgateway.app.listener import co.nilin.opex.bcgateway.app.service.AdminService -import co.nilin.opex.bcgateway.ports.kafka.listener.model.AddCurrencyEvent import co.nilin.opex.bcgateway.ports.kafka.listener.model.AdminEvent -import co.nilin.opex.bcgateway.ports.kafka.listener.model.DeleteCurrencyEvent -import co.nilin.opex.bcgateway.ports.kafka.listener.model.EditCurrencyEvent import co.nilin.opex.bcgateway.ports.kafka.listener.spi.AdminEventListener import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory @@ -18,12 +15,14 @@ class AdminEventListenerImpl(private val adminService: AdminService) : AdminEven override fun id() = "AdminEventListener" override fun onEvent(event: AdminEvent, partition: Int, offset: Long, timestamp: Long): Unit = runBlocking { + //todo check with peyman logger.info("Incoming admin event $event") when (event) { - is AddCurrencyEvent -> adminService.addCurrency(event.name, event.symbol) - is EditCurrencyEvent -> adminService.editCurrency(event.name, event.symbol) - is DeleteCurrencyEvent -> adminService.deleteCurrency(event.name) +// is AddCurrencyEvent -> adminService.addCurrency(event.name, event.symbol) +// is EditCurrencyEvent -> adminService.editCurrency(event.name, event.symbol) +// is DeleteCurrencyEvent -> adminService.deleteCurrency(event.name) else -> {} } } + } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AddressAllocatorJob.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AddressAllocatorJob.kt index 4fda7a2c5..94ca46580 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AddressAllocatorJob.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AddressAllocatorJob.kt @@ -7,18 +7,17 @@ import org.slf4j.Logger import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration import org.springframework.scheduling.annotation.Scheduled -import java.util.concurrent.TimeUnit @Configuration class AddressAllocatorJob(private val addressManager: AddressManager) { private val logger: Logger by LoggerDelegate() - @Value("\${app.address.life-time.value}") - private var lifeTime: Long? = null + @Value("\${app.address.life-time}") + private var addressLifeTime: Long? = null - @Scheduled(fixedDelayString = "\${app.address.life-time.value:0}000") + @Scheduled(fixedDelayString = "60000") fun revokeExpiredAddress() { - if (lifeTime != null) { + if (addressLifeTime != null) { logger.info("going to lookup assigned address .....") runBlocking { addressManager.revokeExpiredAddress() } } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AdminService.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AdminService.kt index aa3b9de76..f9ad972c0 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AdminService.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/AdminService.kt @@ -1,32 +1,32 @@ package co.nilin.opex.bcgateway.app.service import co.nilin.opex.bcgateway.app.dto.AddChainRequest -import co.nilin.opex.bcgateway.app.dto.TokenRequest -import co.nilin.opex.bcgateway.core.model.CurrencyImplementation +import co.nilin.opex.bcgateway.core.model.ReservedAddress import co.nilin.opex.bcgateway.core.spi.AddressTypeHandler import co.nilin.opex.bcgateway.core.spi.ChainLoader -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler +import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler +import co.nilin.opex.common.OpexError import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class AdminService( private val chainLoader: ChainLoader, - private val currencyHandler: CurrencyHandler, - private val addressTypeHandler: AddressTypeHandler + private val addressTypeHandler: AddressTypeHandler, + private val reservedAddressHandler: ReservedAddressHandler, ) { - suspend fun addCurrency(name: String, symbol: String) { - currencyHandler.addCurrency(name, symbol) - } - - suspend fun editCurrency(name: String, symbol: String) { - currencyHandler.editCurrency(name, symbol) - } - - suspend fun deleteCurrency(name: String) { - currencyHandler.deleteCurrency(name) - } +// suspend fun addCurrency(name: String, symbol: String) { +// currencyHandler.addCurrency(name, symbol) +// } +// +// suspend fun editCurrency(name: String, symbol: String) { +// currencyHandler.editCurrency(name, symbol) +// } +// +// suspend fun deleteCurrency(name: String) { +// currencyHandler.deleteCurrency(name) +// } @Transactional suspend fun addChain(body: AddChainRequest) { @@ -37,25 +37,38 @@ class AdminService( addressTypeHandler.addAddressType(name, addressRegex, memoRegex) } - suspend fun addToken(body: TokenRequest): CurrencyImplementation { - return with(body) { - currencyHandler.addCurrencyImplementation( - currencySymbol!!, - implementationSymbol ?: currencySymbol, - chain!!, - tokenName, - tokenAddress, - isToken, - withdrawFee, - minimumWithdraw, - isWithdrawEnabled, - decimal + suspend fun addAddresses(addresses: List, memos: List?, addressType: String) { + var addressTypeObj = + addressTypeHandler.fetchAddressType(addressType) ?: throw OpexError.InvalidAddressType.exception() + val reservedAddresses = addresses.mapIndexed { index, address -> + ReservedAddress( + address = address, + memo = memos?.getOrNull(index).orEmpty(), + type = addressTypeObj ) } + reservedAddressHandler.addReservedAddress(reservedAddresses) } - suspend fun changeTokenWithdrawStatus(symbol: String, chain: String, status: Boolean) { - currencyHandler.changeWithdrawStatus(symbol, chain, status) - } +// suspend fun addToken(body: TokenRequest): CryptoCurrencyCommand { +// return with(body) { +// currencyHandler.addCurrencyImplementation( +// currencySymbol!!, +// implementationSymbol ?: currencySymbol, +// chain!!, +// tokenName, +// tokenAddress, +// isToken, +// withdrawFee, +// minimumWithdraw, +// isWithdrawEnabled, +// decimal +// ) +// } +// } + +// suspend fun changeTokenWithdrawStatus(symbol: String, chain: String, status: Boolean) { +// currencyHandler.changeWithdrawStatus(symbol, chain, status) +// } } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/OmniBalanceService.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/OmniBalanceService.kt new file mode 100644 index 000000000..6994196e6 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/service/OmniBalanceService.kt @@ -0,0 +1,58 @@ +package co.nilin.opex.bcgateway.app.service + +import co.nilin.opex.bcgateway.core.model.FetchGateways +import co.nilin.opex.bcgateway.core.spi.CryptoCurrencyHandlerV2 +import co.nilin.opex.bcgateway.core.spi.OmniWalletManager +import co.nilin.opex.common.OpexError +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal + +@Service +class OmniBalanceService( + private val cryptoCurrencyHandlerV2: CryptoCurrencyHandlerV2, + private val omniWalletManager: OmniWalletManager +) { + + data class OmniBalanceForCurrency(val currency: String, val balance: BigDecimal? = BigDecimal.ZERO) + data class OmniBalance(val data: ArrayList? = ArrayList()) + + private val logger = LoggerFactory.getLogger(OmniBalanceService::class.java) + + suspend fun fetchSystemBalance(currency: String): OmniBalanceForCurrency { + val currencyImpls = + cryptoCurrencyHandlerV2.fetchCurrencyOnChainGateways(FetchGateways(currencySymbol = currency)) + ?: throw OpexError.CurrencyNotFound.exception() + val totalBalance: BigDecimal? = currencyImpls?.map { + when (it.isToken) { + true -> it.tokenAddress?.let { ta -> omniWalletManager.getTokenBalance(it).balance } + ?: BigDecimal.ZERO + + false -> omniWalletManager.getAssetBalance(it).balance ?: BigDecimal.ZERO + else -> BigDecimal.ZERO + } + }?.reduce { a, b -> a + b } + return OmniBalanceForCurrency(currency = currency, balance = totalBalance) + } + + suspend fun fetchSystemBalance(): List? { + val currencyImpls = cryptoCurrencyHandlerV2.fetchCurrencyOnChainGateways(FetchGateways()) + ?: throw OpexError.CurrencyNotFound.exception() + val implsGroupedByCurrency = currencyImpls?.groupBy { it.currencySymbol } + val result = ArrayList() + for (currency in implsGroupedByCurrency?.keys ?: emptyList()) { + val balance = implsGroupedByCurrency?.get(currency)?.map { + when (it.isToken) { + true -> it.tokenAddress?.let { ta -> omniWalletManager.getTokenBalance(it).balance } + ?: BigDecimal.ZERO + + false -> omniWalletManager.getAssetBalance(it).balance ?: BigDecimal.ZERO + else -> BigDecimal.ZERO + } + }?.reduce { a, b -> a + b } + result.add(OmniBalanceForCurrency(currency, balance)) + } + return result.toList() + } + +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/AudienceValidator.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..4104865e9 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.bcgateway.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/Extensions.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/Extensions.kt index f7306eea4..121c5fc88 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/Extensions.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/Extensions.kt @@ -6,8 +6,8 @@ import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.oauth2.jwt.Jwt fun ServerHttpSecurity.AuthorizeExchangeSpec.Access.hasRole( - authority: String, - role: String + authority: String, + role: String ): ServerHttpSecurity.AuthorizeExchangeSpec = access { mono, _ -> mono.map { auth -> val hasAuthority = auth.authorities.any { it.authority == authority } @@ -17,12 +17,12 @@ fun ServerHttpSecurity.AuthorizeExchangeSpec.Access.hasRole( } fun ServerHttpSecurity.AuthorizeExchangeSpec.Access.hasRoleAndLevel( - role: String, - level: String?=null + role: String, + level: String? = null ): ServerHttpSecurity.AuthorizeExchangeSpec = access { mono, _ -> mono.map { auth -> val hasLevel = level?.let { ((auth.principal as Jwt).claims["level"] as String?)?.equals(level) == true } - ?: true + ?: true val hasRole = ((auth.principal as Jwt).claims["roles"] as JSONArray?)?.contains(role) == true AuthorizationDecision(hasLevel && hasRole) } diff --git a/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml b/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml index 2661b7831..cfada5e5f 100644 --- a/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml +++ b/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml @@ -14,6 +14,18 @@ spring: username: ${dbusername:opex} password: ${dbpassword:hiopex} initialization-mode: always + pool: + initial-size: 5 + max-size: 32 + max-idle-time: 10m + max-life-time: 30m + max-acquire-time: 10s + max-create-connection-time: 10s + validation-query: SELECT 1 + datasource: + url: jdbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} cloud: bootstrap: enabled: true @@ -66,13 +78,20 @@ logging: swagger: authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token app: + omni-wallet: + url: http://app:8080 #todo ->env auth: url: ${auth_url} cert-url: ${auth_jwk_endpoint} + iss-url: client-id: ${client_id} client-secret: ${client_secret} wallet: url: http://wallet:8080 address: - life-time: - value: ${ADDRESS_EXP_TIME} # second + life-time: ${ADDRESS_EXP_TIME} # second + custom-message: + 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 diff --git a/bc-gateway/bc-gateway-app/src/main/resources/application.yml b/bc-gateway/bc-gateway-app/src/main/resources/application.yml index 09aafe8ff..1071efc90 100644 --- a/bc-gateway/bc-gateway-app/src/main/resources/application.yml +++ b/bc-gateway/bc-gateway-app/src/main/resources/application.yml @@ -15,6 +15,18 @@ spring: username: ${dbusername:opex} password: ${dbpassword:hiopex} initialization-mode: always + pool: + initial-size: 5 + max-size: 32 + max-idle-time: 10m + max-life-time: 30m + max-acquire-time: 10s + max-create-connection-time: 10s + validation-query: SELECT 1 + datasource: + url: jdbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} cloud: bootstrap: enabled: true @@ -48,7 +60,7 @@ management: web: base-path: /actuator exposure: - include: ["health", "prometheus", "metrics"] + include: [ "health", "prometheus", "metrics" ] endpoint: health: show-details: when_authorized @@ -99,16 +111,23 @@ logging: co.nilin: INFO org.zalando.logbook: TRACE app: + omni-wallet: + url: localhost:8080 #todo -> env auth: url: lb://opex-auth - cert-url: lb://opex-auth/auth/realms/opex/protocol/openid-connect/certs + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} client-id: none client-secret: none wallet: url: lb://opex-wallet address: - life-time: - value: ${ADDRESS_EXP_TIME} # second + life-time: 167234670 + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} swagger: authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token} diff --git a/bc-gateway/bc-gateway-app/src/main/resources/scanner-public.pem b/bc-gateway/bc-gateway-app/src/main/resources/scanner-public.pem new file mode 100644 index 000000000..8180c26d9 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/resources/scanner-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqn6tRj45adbvNt6TzxYg +zo4WaREorv91NM5vhQ+wXeY787EmPsQ/mZqyX6yyo5+RnBy9M4mU7ADrS3jQmi+4 +jMCncGrylgYTGAtsY9O6x0sVM/aG7Na3jlXqL/0ZeLyMv0uo0IWhzcapSSTnozOz +oGAyp/VLmQ5Jtk9wgxQlz67sqFMHXRzF4p3/I15eZu7td1oEViHaY1rArXYOsPMD +M4/avAr50kYtP995hApGEdxw1mlBrXyB1Wy7cHuuRDJ7BY3jY8gLE/JJddmKH1/i +vFvKM+K5If2d50qhkI6SQ0aTZ0lEnQsVcdt4o2HT1hxsA8TIz6N1+xVoFjJ27Ry6 +yQIDAQAB +-----END PUBLIC KEY----- diff --git a/bc-gateway/bc-gateway-core/pom.xml b/bc-gateway/bc-gateway-core/pom.xml index 0dce4d6ce..cbd3c236a 100644 --- a/bc-gateway/bc-gateway-core/pom.xml +++ b/bc-gateway/bc-gateway-core/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AssignAddressService.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AssignAddressService.kt index 069db26cc..eb6de7d38 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AssignAddressService.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AssignAddressService.kt @@ -1,8 +1,18 @@ package co.nilin.opex.bcgateway.core.api +import co.nilin.opex.bcgateway.core.model.AddressStatus import co.nilin.opex.bcgateway.core.model.AssignedAddress -import co.nilin.opex.bcgateway.core.model.Currency + +//import co.nilin.opex.bcgateway.core.model.Currency interface AssignAddressService { - suspend fun assignAddress(user: String, currency: Currency, chain: String): List + suspend fun assignAddress(user: String, currency: String, gatewayUuid: String): List + /** + * Find the holder (uuid) and status of an address. + * @param address The on-chain address. + * @param memo Optional memo/tag for chains that require it. + * @param time If non-null, represents epoch milliseconds specifying the moment to check; if null, checks current. + * @return Pair of (uuid at the requested time or current, current status). Both may be null when unassigned. + */ + suspend fun findHolder(address: String, memo: String? = null, time: Long?): Pair } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/WalletSyncService.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/WalletSyncService.kt index 68a2032c9..584801888 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/WalletSyncService.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/WalletSyncService.kt @@ -3,5 +3,9 @@ package co.nilin.opex.bcgateway.core.api import co.nilin.opex.bcgateway.core.model.Transfer interface WalletSyncService { + + suspend fun sendTransfer(transfer: Transfer) + + @Deprecated("Use above function instead") suspend fun syncTransfers(transfers: List) } diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AssignedAddress.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AssignedAddress.kt index 47bdd13e6..9dfe6e5db 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AssignedAddress.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AssignedAddress.kt @@ -12,7 +12,7 @@ data class AssignedAddress( var assignedDate: LocalDateTime? = null, var revokedDate: LocalDateTime? = null, var status: AddressStatus? = AddressStatus.Reserved, - var id: Long?=null + var id: Long? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AssignedAddressV2.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AssignedAddressV2.kt new file mode 100644 index 000000000..918423b3c --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AssignedAddressV2.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.bcgateway.core.model + +import java.time.LocalDateTime + +data class AssignedAddressV2( + val typeId: Long, + val address: String, + val memo: String?, + var expTime: LocalDateTime? = null, + var assignedDate: LocalDateTime? = null, + var revokedDate: LocalDateTime? = null, + var status: AddressStatus? = AddressStatus.Reserved, + var id: Long? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AssignedAddressV2 + + if (address != other.address) return false + if (memo != other.memo) return false + + return true + } + + override fun hashCode(): Int { + var result = address.hashCode() + result = 31 * result + (memo?.hashCode() ?: 0) + return result + } +} diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt index 074ae2ff1..1b6c98000 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt @@ -1,3 +1,6 @@ package co.nilin.opex.bcgateway.core.model -data class Chain(val name: String, val addressTypes: List) +data class Chain( + val name: String, + val addressTypes: List, + val externalChinScannerUrl: String? = null) diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CryptoCurrencyCommand.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CryptoCurrencyCommand.kt new file mode 100644 index 000000000..ca86ba6df --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CryptoCurrencyCommand.kt @@ -0,0 +1,41 @@ +package co.nilin.opex.bcgateway.core.model + +import java.math.BigDecimal + +data class CryptoCurrencyCommand( + var currencySymbol: String, + var gatewayUuid: String?, + var implementationSymbol: String? = currencySymbol, + var isDepositActive: Boolean? = true, + var isWithdrawActive: Boolean? = true, + var isToken: Boolean? = false, + var tokenName: String? = null, + var tokenAddress: String? = null, + var withdrawFee: BigDecimal?, + var withdrawAllowed: Boolean? = true, + var depositAllowed: Boolean? = true, + val withdrawMin: BigDecimal? = BigDecimal.ZERO, + var withdrawMax: BigDecimal? = BigDecimal.ZERO, + var depositMin: BigDecimal? = BigDecimal.ZERO, + var depositMax: BigDecimal? = BigDecimal.ZERO, + var decimal: Int, + var chain: String, + var depositDescription: String? = null, + var withdrawDescription: String? = null, + var displayOrder: Int? = null, + var type: String = "OnChain", + +// var chainDetail: Chain? = null + + +) { + fun updateTo(newData: CryptoCurrencyCommand): CryptoCurrencyCommand { + return newData.apply { + this.currencySymbol = currencySymbol + this.gatewayUuid = gatewayUuid + } + } + +} + + diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Currency.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Currency.kt index a66ef7a84..580f36d28 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Currency.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Currency.kt @@ -1,3 +1,3 @@ -package co.nilin.opex.bcgateway.core.model - -data class Currency(val symbol: String, val name: String) +//package co.nilin.opex.bcgateway.core.model +// +//data class Currency(val symbol: String, val name: String) diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyImplementation.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyImplementation.kt index 7eb58a866..9e2f544c8 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyImplementation.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyImplementation.kt @@ -3,14 +3,16 @@ package co.nilin.opex.bcgateway.core.model import java.math.BigDecimal data class CurrencyImplementation( - val currency: Currency, - val implCurrency: Currency, - val chain: Chain, + val currency: String, + val implCurrency: String, + val chain: String, val token: Boolean, val tokenAddress: String?, val tokenName: String?, val withdrawEnabled: Boolean, val withdrawFee: BigDecimal, val withdrawMin: BigDecimal, - val decimal: Int + val decimal: Int, +// val chainDetail:Chain?=null, + ) diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyInfo.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyInfo.kt index fdb9ad665..2a350b440 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyInfo.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyInfo.kt @@ -1,3 +1,3 @@ package co.nilin.opex.bcgateway.core.model -data class CurrencyInfo(val currency: Currency, val implementations: List) +data class CurrencyInfo(val currency: String, val implementations: List) diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyOnChainGatewayLocalizationCommand.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyOnChainGatewayLocalizationCommand.kt new file mode 100644 index 000000000..e755663b9 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyOnChainGatewayLocalizationCommand.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.bcgateway.core.model + + +data class CurrencyOnChainGatewayLocalizationCommand( + var id: Long? = null, + var depositDescription: String? = null, + var withdrawDescription: String? = null, + var language: String +) + + + + diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyOnChainGatewayView.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyOnChainGatewayView.kt new file mode 100644 index 000000000..7e0100e49 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/CurrencyOnChainGatewayView.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.bcgateway.core.model + +import java.math.BigDecimal + +data class CurrencyOnChainGatewayView( + var id: Long? = null, + val gatewayUuid: String, + val currencySymbol: String, + var implementationSymbol: String? = currencySymbol, + var chain: String, + var isToken: Boolean? = false, + var tokenAddress: String? = null, + var tokenName: String? = null, + var withdrawAllowed: Boolean, + var depositAllowed: Boolean, + var withdrawFee: BigDecimal, + var withdrawMin: BigDecimal? = BigDecimal.ZERO, + var withdrawMax: BigDecimal? = BigDecimal.ZERO, + var depositMin: BigDecimal? = BigDecimal.ZERO, + var depositMax: BigDecimal? = BigDecimal.ZERO, + var decimal: Int, + var isDepositActive: Boolean? = true, + var isWithdrawActive: Boolean? = true, + val depositDescription: String? = null, + val withdrawDescription: String? = null, + val displayOrder: Int? = null +) + + diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Deposit.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Deposit.kt index 01b728da9..ac44765b4 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Deposit.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Deposit.kt @@ -1,16 +1,15 @@ package co.nilin.opex.bcgateway.core.model import java.math.BigDecimal -import java.time.LocalDateTime data class Deposit( - val id: Long?, - val hash: String, - val depositor: String, - val depositorMemo: String?, - val amount: BigDecimal, - val chain: String, - val token: Boolean, - val tokenAddress: String?, + val id: Long?, + val hash: String, + val depositor: String, + val depositorMemo: String?, + val amount: BigDecimal, + val chain: String, + val token: Boolean, + val tokenAddress: String?, -) \ No newline at end of file + ) \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/FetchGateways.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/FetchGateways.kt new file mode 100644 index 000000000..3af7b2ad0 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/FetchGateways.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.bcgateway.core.model + +data class FetchGateways( + val currencySymbol: String? = null, + var gatewayUuid: String? = null, + var chain: String? = null, + var currencyImplementationName: String? = null +) \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/OmniBalance.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/OmniBalance.kt new file mode 100644 index 000000000..76d6bb134 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/OmniBalance.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.bcgateway.core.model + +import java.math.BigDecimal + +data class OmniBalance(val currency: String, val network: String, val balance: BigDecimal? = BigDecimal.ZERO) + diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Transfer.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Transfer.kt index e01b440c5..e33ffbd99 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Transfer.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Transfer.kt @@ -5,7 +5,6 @@ import java.math.BigInteger data class Transfer( val txHash: String, - val blockNumber: BigInteger, val receiver: Wallet, val isTokenTransfer: Boolean, val amount: BigDecimal, diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginRequest.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginRequest.kt index 7d9dc1c70..eac149b2c 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginRequest.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginRequest.kt @@ -1,3 +1,3 @@ package co.nilin.opex.bcgateway.core.model.otc -data class LoginRequest(var clientId:String, var clientSecret:String) +data class LoginRequest(var clientId: String, var clientSecret: String) diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginResponse.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginResponse.kt index 8a03bffdc..d8c03116c 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginResponse.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/otc/LoginResponse.kt @@ -4,7 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty data class LoginResponse(var data: Token) -data class Token(@JsonProperty("access_token") - val accessToken: String, - @JsonProperty("expires_in") - val expireIn: Long) +data class Token( + @JsonProperty("access_token") + val accessToken: String, + @JsonProperty("expires_in") + val expireIn: Long +) diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt index 37516d23b..bf754dcb3 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt @@ -1,71 +1,67 @@ package co.nilin.opex.bcgateway.core.service import co.nilin.opex.bcgateway.core.api.AssignAddressService -import co.nilin.opex.bcgateway.core.model.* +import co.nilin.opex.bcgateway.core.model.AddressStatus +import co.nilin.opex.bcgateway.core.model.AssignedAddress import co.nilin.opex.bcgateway.core.spi.AssignedAddressHandler -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler +import co.nilin.opex.bcgateway.core.spi.ChainLoader +import co.nilin.opex.bcgateway.core.spi.CryptoCurrencyHandlerV2 import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler import co.nilin.opex.bcgateway.core.utils.LoggerDelegate import co.nilin.opex.common.OpexError -import co.nilin.opex.utility.error.data.OpexException import org.slf4j.Logger import org.springframework.beans.factory.annotation.Value import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId open class AssignAddressServiceImpl( - private val currencyHandler: CurrencyHandler, - private val assignedAddressHandler: AssignedAddressHandler, - private val reservedAddressHandler: ReservedAddressHandler + private val currencyHandler: CryptoCurrencyHandlerV2, + private val assignedAddressHandler: AssignedAddressHandler, + private val reservedAddressHandler: ReservedAddressHandler, + private val chainLoader: ChainLoader + ) : AssignAddressService { - @Value("\${app.address.life-time.value}") - private var lifeTime: Long? = null + @Value("\${app.address.life-time}") + private var addressLifeTime: Long? = null private val logger: Logger by LoggerDelegate() @Transactional - override suspend fun assignAddress(user: String, currency: Currency, chain: String): List { - logger.info(ZoneId.systemDefault().toString()) - val currencyInfo = currencyHandler.fetchCurrencyInfo(currency.symbol) - val chains = currencyInfo.implementations - .map { imp -> imp.chain } - .filter { it.name.equals(chain, true) } - val addressTypes = chains - .flatMap { chain -> chain.addressTypes } - .distinct() - val chainAddressTypeMap = HashMap>() - chains.forEach { chain -> - chain.addressTypes.forEach { addressType -> - chainAddressTypeMap.putIfAbsent(addressType, mutableListOf()) - chainAddressTypeMap.getValue(addressType).add(chain) - } - } - val userAssignedAddresses = (assignedAddressHandler.fetchAssignedAddresses(user, addressTypes)).toMutableList() + override suspend fun assignAddress(user: String, currency: String, gatewayUuid: String): List { + + addressLifeTime = 7200 + + val requestedGateway = currencyHandler.fetchOnChainGateway(currency = currency, gatewayUuid = gatewayUuid) + ?: throw OpexError.CurrencyNotFound.exception() + + val requestedChain = chainLoader.fetchChainInfo(requestedGateway.chain) + val addressTypes = requestedChain?.addressTypes ?: throw OpexError.BadRequest.exception() + + val userAssignedAddresses = + (assignedAddressHandler.fetchAssignedAddresses(user, addressTypes!!)).toMutableList() + val result = mutableSetOf() + addressTypes.forEach { addressType -> val assigned = userAssignedAddresses.firstOrNull { assignAddress -> assignAddress.type == addressType } if (assigned != null) { - chainAddressTypeMap[addressType]?.forEach { chain -> - if (!assigned.chains.contains(chain)) { - assigned.chains.add(chain) - } - } result.add(assigned) } else { val reservedAddress = reservedAddressHandler.peekReservedAddress(addressType) if (reservedAddress != null) { val newAssigned = AssignedAddress( - user, - reservedAddress.address, - reservedAddress.memo, - addressType, - chainAddressTypeMap[addressType]!!, - lifeTime?.let { LocalDateTime.now().plusSeconds(lifeTime!!) } - ?: null, - LocalDateTime.now(), - null, - AddressStatus.Assigned, - null + user, + reservedAddress.address, + reservedAddress.memo, + addressType, + listOf(requestedChain).toMutableList(), + addressLifeTime?.let { LocalDateTime.now().plusSeconds(addressLifeTime!!) } + ?: null, + LocalDateTime.now(), + null, + AddressStatus.Assigned, + null ) reservedAddressHandler.remove(reservedAddress) result.add(newAssigned) @@ -78,9 +74,17 @@ open class AssignAddressServiceImpl( } result.forEach { address -> assignedAddressHandler.persist(address) - address.apply { id=null } + address.apply { id = null } } return result.toMutableList() } + override suspend fun findHolder(address: String, memo: String?, time: Long?): Pair { + val at: LocalDateTime? = time?.let { ts -> + val instant = Instant.ofEpochMilli(ts) + LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) + } + return assignedAddressHandler.findHolder(address, memo, at) + } + } diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplV2.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplV2.kt new file mode 100644 index 000000000..755a84199 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplV2.kt @@ -0,0 +1,69 @@ +//package co.nilin.opex.bcgateway.core.service +// +//import co.nilin.opex.bcgateway.core.api.AssignAddressService +//import co.nilin.opex.bcgateway.core.model.* +//import co.nilin.opex.bcgateway.core.spi.* +//import co.nilin.opex.bcgateway.core.utils.LoggerDelegate +//import co.nilin.opex.common.OpexError +//import org.slf4j.Logger +//import org.springframework.beans.factory.annotation.Value +//import org.springframework.transaction.annotation.Transactional +//import java.time.LocalDateTime +//import java.time.ZoneId +// +//open class AssignAddressServiceImplV2( +// private val currencyHandler: CryptoCurrencyHandlerV2, +// private val assignedAddressHandler: AssignedAddressHandler, +// private val reservedAddressHandler: ReservedAddressHandler, +// private val chainLoader: ChainLoader +//) : AssignAddressService { +// @Value("\${app.address.life-time}") +// private var lifeTime: Long? = null +// private val logger: Logger by LoggerDelegate() +// +// override suspend fun assignAddress(user: String, currencyImplUuid: String): List { +// logger.info(ZoneId.systemDefault().toString()) +// val result = mutableSetOf() +// currencyHandler.fetchCurrencyImpls(FetchImpls(currencyImplUuid)) +// ?.imps?.firstOrNull()?.let { it -> +// //for requested chain check all available address types and for each of them do : +// chainLoader.fetchChainInfo(it.chain!!).addressTypes.map { it -> it.id }.distinct() +// .forEach { addressType -> +// //check: Is there any assigned address(user, specific address type on requested chain) +// assignedAddressHandler.fetchAssignedAddresses(user, addressType)?.let { +// result.add(it) +// } ?: run { +// // there is no assigned address(user,specific address type on requested chain) +// //then assign new address (ip applicable) +// assignNewAddress(user, addressType)?.let { ra -> +// result.add(ra) +// } +// } +// +// +// } +// if (result.size == 0) +// throw OpexError.ReservedAddressNotAvailable.exception() +// return result.toMutableList() +// } ?: throw OpexError.BadRequest.exception() +// +// } +// +// @Transactional +// open suspend fun assignNewAddress(user: String, addressTypeId: Long): AssignedAddressV2? { +// reservedAddressHandler.peekReservedAddress(addressTypeId)?.let {// +// reservedAddressHandler.remove(it) +// return assignedAddressHandler.persist(user, +// AssignedAddressV2(addressTypeId, it.address, it.memo, +// lifeTime?.let { LocalDateTime.now().plusSeconds(lifeTime!!) } ?: null, +// LocalDateTime.now(), +// null, +// AddressStatus.Assigned, +// null)) +// } +// +// ?: run { return null } +// +// } +// +//} diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/WalletSyncServiceImpl.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/WalletSyncServiceImpl.kt index 997811691..8f0938eea 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/WalletSyncServiceImpl.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/WalletSyncServiceImpl.kt @@ -1,15 +1,15 @@ package co.nilin.opex.bcgateway.core.service import co.nilin.opex.bcgateway.core.api.WalletSyncService -import co.nilin.opex.bcgateway.core.model.CurrencyImplementation +import co.nilin.opex.bcgateway.core.model.CryptoCurrencyCommand import co.nilin.opex.bcgateway.core.model.Deposit import co.nilin.opex.bcgateway.core.model.Transfer import co.nilin.opex.bcgateway.core.spi.AssignedAddressHandler -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler +import co.nilin.opex.bcgateway.core.spi.CryptoCurrencyHandlerV2 import co.nilin.opex.bcgateway.core.spi.DepositHandler import co.nilin.opex.bcgateway.core.spi.WalletProxy import co.nilin.opex.bcgateway.core.utils.LoggerDelegate -import kotlinx.coroutines.async +import co.nilin.opex.common.OpexError import kotlinx.coroutines.coroutineScope import org.slf4j.Logger import org.springframework.stereotype.Service @@ -20,23 +20,55 @@ import java.math.BigDecimal class WalletSyncServiceImpl( private val walletProxy: WalletProxy, private val assignedAddressHandler: AssignedAddressHandler, - private val currencyHandler: CurrencyHandler, + private val currencyHandler: CryptoCurrencyHandlerV2, private val depositHandler: DepositHandler ) : WalletSyncService { private val logger: Logger by LoggerDelegate() + @Transactional + override suspend fun sendTransfer(transfer: Transfer) { + val uuid = assignedAddressHandler.findUuid(transfer.receiver.address, transfer.receiver.memo) ?: return + val currencyGateway = + currencyHandler.fetchGatewayWithoutSymbol(transfer.chain, transfer.isTokenTransfer, transfer.tokenAddress) + ?: throw OpexError.CurrencyNotFound.exception() + + depositHandler.save( + with(transfer) { + Deposit( + null, + txHash, + receiver.address, + receiver.memo, + amount, + chain, + isTokenTransfer, + tokenAddress + ) + } + ) + + sendDeposit(uuid, currencyGateway, transfer) + } + @Transactional override suspend fun syncTransfers(transfers: List) = coroutineScope { - val groupedByChain = currencyHandler.fetchAllImplementations().groupBy { it.chain.name } + val groupedByChain = currencyHandler.fetchCurrencyOnChainGateways()?.groupBy { it.chain } + ?: throw OpexError.CurrencyNotFound.exception() val deposits = transfers.mapNotNull { coroutineScope { + val currencyImpl = groupedByChain[it.chain]?.find { c -> c.tokenAddress == it.tokenAddress } - ?: throw IllegalStateException("Currency implementation not found") - assignedAddressHandler.findUuid(it.receiver.address, it.receiver.memo)?.let { it to currencyImpl } + ?: run { + logger.info("Currency implementation not found") + return@coroutineScope null + } + assignedAddressHandler.findUuid(it.receiver.address, it.receiver.memo)?.let { + it to currencyImpl + } }?.let { (uuid, currencyImpl) -> sendDeposit(uuid, currencyImpl, it) - logger.info("Deposit synced for $uuid on ${currencyImpl.currency.symbol} - to ${it.receiver.address}") + logger.info("Deposit synced for $uuid on ${currencyImpl.currencySymbol} - to ${it.receiver.address}") it } }.map { @@ -54,10 +86,10 @@ class WalletSyncServiceImpl( depositHandler.saveAll(deposits) } - private suspend fun sendDeposit(uuid: String, currencyImpl: CurrencyImplementation, transfer: Transfer) { + private suspend fun sendDeposit(uuid: String, currencyImpl: CryptoCurrencyCommand, transfer: Transfer) { val amount = transfer.amount.divide(BigDecimal.TEN.pow(currencyImpl.decimal)) - val symbol = currencyImpl.currency.symbol + val symbol = currencyImpl.currencySymbol logger.info("Sending deposit to $uuid - $amount $symbol") - walletProxy.transfer(uuid, symbol, amount, transfer.txHash,transfer.chain) + walletProxy.transfer(uuid, symbol, amount, transfer.txHash, transfer.chain, currencyImpl.gatewayUuid) } } diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AddressTypeHandler.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AddressTypeHandler.kt index 6a60f06aa..282dfe489 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AddressTypeHandler.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AddressTypeHandler.kt @@ -8,4 +8,5 @@ interface AddressTypeHandler { suspend fun addAddressType(name: String, addressRegex: String, memoRegex: String?) + suspend fun fetchAddressType(name: String): AddressType? } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt index e6a4d12fe..ad506df02 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt @@ -1,5 +1,6 @@ package co.nilin.opex.bcgateway.core.spi +import co.nilin.opex.bcgateway.core.model.AddressStatus import co.nilin.opex.bcgateway.core.model.AddressType import co.nilin.opex.bcgateway.core.model.AssignedAddress import java.time.LocalDateTime @@ -14,4 +15,10 @@ interface AssignedAddressHandler { suspend fun fetchExpiredAssignedAddresses(): List? + /** + * Find the holder (uuid) and status of an address at a given time. + * If [at] is null, returns the current holder (status Assigned) if any. + * Returns Pair, where both can be null when unassigned. + */ + suspend fun findHolder(address: String, memo: String?, at: LocalDateTime?): Pair } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AuthProxy.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AuthProxy.kt index 8cbf15f68..ac9f21f6f 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AuthProxy.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AuthProxy.kt @@ -1,8 +1,9 @@ package co.nilin.opex.bcgateway.core.spi -import co.nilin.opex.bcgateway.core.model.otc.* +import co.nilin.opex.bcgateway.core.model.otc.LoginRequest +import co.nilin.opex.bcgateway.core.model.otc.LoginResponse interface AuthProxy { - suspend fun getToken(loginRequest: LoginRequest):LoginResponse + suspend fun getToken(loginRequest: LoginRequest): LoginResponse } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ChainLoader.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ChainLoader.kt index ac274c2e0..6c3f6bd4c 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ChainLoader.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ChainLoader.kt @@ -4,9 +4,9 @@ import co.nilin.opex.bcgateway.core.model.Chain interface ChainLoader { - suspend fun addChain(name: String, addressType:String):Chain + suspend fun addChain(name: String, addressType: String): Chain - suspend fun fetchAllChains():List + suspend fun fetchAllChains(): List suspend fun fetchChainInfo(chain: String): Chain } diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/CryptoCurrencyHandlerV2.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/CryptoCurrencyHandlerV2.kt new file mode 100644 index 000000000..bb0f4d582 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/CryptoCurrencyHandlerV2.kt @@ -0,0 +1,38 @@ +package co.nilin.opex.bcgateway.core.spi + +import co.nilin.opex.bcgateway.core.model.CryptoCurrencyCommand +import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayLocalizationCommand +import co.nilin.opex.bcgateway.core.model.FetchGateways +import co.nilin.opex.bcgateway.core.model.WithdrawData + +interface CryptoCurrencyHandlerV2 { + + suspend fun createOnChainGateway(request: CryptoCurrencyCommand): CryptoCurrencyCommand? + + suspend fun updateOnChainGateway(request: CryptoCurrencyCommand): CryptoCurrencyCommand? + + suspend fun deleteOnChainGateway(gatewayUuid: String, currency: String): Void? + + suspend fun fetchCurrencyOnChainGateways(data: FetchGateways? = null): List? + + suspend fun fetchOnChainGateway(gatewayUuid: String, currency: String): CryptoCurrencyCommand? + + suspend fun changeWithdrawStatus(symbol: String, chain: String, status: Boolean) + + suspend fun getWithdrawData(symbol: String, network: String): WithdrawData + + suspend fun fetchGatewayWithoutSymbol( + chain: String, + isToken: Boolean, + tokenAddress: String? + ): CryptoCurrencyCommand? + + suspend fun saveOnChainGatewayLocalization( + gatewayUuid: String, + localizations: List + ): List + + suspend fun fetchOnChainGatewayLocalizations(gatewayUuid: String): List + suspend fun deleteOnChainGatewayLocalizations(id: Long) + +} diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/CurrencyHandler.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/CurrencyHandler.kt index 768141df4..e69de29bb 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/CurrencyHandler.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/CurrencyHandler.kt @@ -1,74 +0,0 @@ -package co.nilin.opex.bcgateway.core.spi - -import co.nilin.opex.bcgateway.core.model.CurrencyImplementation -import co.nilin.opex.bcgateway.core.model.CurrencyInfo -import co.nilin.opex.bcgateway.core.model.WithdrawData -import java.math.BigDecimal - -interface CurrencyHandler { - - suspend fun addCurrency(name: String, symbol: String) - - suspend fun addCurrencyImplementationV2( - currencySymbol: String, - implementationSymbol: String, - currencyName: String, - chain: String, - tokenName: String?, - tokenAddress: String?, - isToken: Boolean, - withdrawFee: BigDecimal, - minimumWithdraw: BigDecimal, - isWithdrawEnabled: Boolean, - decimal: Int - ): CurrencyImplementation? - - - suspend fun updateCurrencyImplementation( - currencySymbol: String, - implementationSymbol: String, - currencyName: String, - newChain: String? = null, - tokenName: String?, - tokenAddress: String?, - isToken: Boolean, - withdrawFee: BigDecimal, - minimumWithdraw: BigDecimal, - isWithdrawEnabled: Boolean, - decimal: Int, - chain: String - ): CurrencyImplementation? - - - suspend fun editCurrency(name: String, symbol: String) - - suspend fun deleteCurrency(name: String) - - suspend fun addCurrencyImplementation( - currencySymbol: String, - implementationSymbol: String, - chain: String, - tokenName: String?, - tokenAddress: String?, - isToken: Boolean, - withdrawFee: BigDecimal, - minimumWithdraw: BigDecimal, - isWithdrawEnabled: Boolean, - decimal: Int - ): CurrencyImplementation - - suspend fun fetchAllImplementations(): List - - suspend fun fetchCurrencyInfo(symbol: String): CurrencyInfo - - suspend fun findByChainAndTokenAddress(chain: String, address: String?): CurrencyImplementation? - - suspend fun findImplementationsWithTokenOnChain(chain: String): List - - suspend fun findImplementationsByCurrency(currency: String): List - - suspend fun changeWithdrawStatus(symbol: String, chain: String, status: Boolean) - - suspend fun getWithdrawData(symbol: String, network: String): WithdrawData - -} diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/DepositHandler.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/DepositHandler.kt index a8373e322..8451688db 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/DepositHandler.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/DepositHandler.kt @@ -3,8 +3,10 @@ package co.nilin.opex.bcgateway.core.spi import co.nilin.opex.bcgateway.core.model.Deposit interface DepositHandler { + suspend fun findDepositsByHash(hash: List): List - suspend fun saveAll(deposits: List) + suspend fun saveAll(deposits: List) + suspend fun save(deposit: Deposit) } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/OmniWalletManager.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/OmniWalletManager.kt new file mode 100644 index 000000000..85901e004 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/OmniWalletManager.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.bcgateway.core.spi + +import co.nilin.opex.bcgateway.core.model.CryptoCurrencyCommand +import co.nilin.opex.bcgateway.core.model.OmniBalance + +interface OmniWalletManager { + + suspend fun getTokenBalance(currencyImpl: CryptoCurrencyCommand): OmniBalance + suspend fun getAssetBalance(cryptoCurrencyCommand: CryptoCurrencyCommand): OmniBalance + +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/WalletProxy.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/WalletProxy.kt index 1c932e776..be1ee0c49 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/WalletProxy.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/WalletProxy.kt @@ -3,5 +3,12 @@ package co.nilin.opex.bcgateway.core.spi import java.math.BigDecimal interface WalletProxy { - suspend fun transfer(uuid: String, symbol: String, amount: BigDecimal, hash: String, chain: String) + suspend fun transfer( + uuid: String, + symbol: String, + amount: BigDecimal, + hash: String, + chain: String, + gatewayUuid: String? + ) } diff --git a/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt b/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt index 8aa515ff6..404845d6c 100644 --- a/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt +++ b/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt @@ -1,29 +1,34 @@ package co.nilin.opex.bcgateway.core.service +//import co.nilin.opex.bcgateway.core.spi.CurrencyHandler import co.nilin.opex.bcgateway.core.model.* -import co.nilin.opex.bcgateway.core.model.Currency import co.nilin.opex.bcgateway.core.spi.AssignedAddressHandler -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler +import co.nilin.opex.bcgateway.core.spi.ChainLoader +import co.nilin.opex.bcgateway.core.spi.CryptoCurrencyHandlerV2 import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Test import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test import java.math.BigDecimal import java.util.* class AssignAddressServiceImplUnitTest { - private val currencyHandler = mockk() + private val currencyHandler = mockk() private val assignedAddressHandler = mockk() private val reservedAddressHandler = mockk() + private val chainLoader = mockk() + private val assignAddressServiceImpl = - AssignAddressServiceImpl(currencyHandler, assignedAddressHandler, reservedAddressHandler) + AssignAddressServiceImpl(currencyHandler, assignedAddressHandler, reservedAddressHandler, chainLoader) - private val currency = Currency("ETH", "Ethereum") + // private val currency = Currency("ETH", "Ethereum") private val chain = "ETH_MAINNET" + private val gatewayUuid = "1" + private val currency = "ETH" private val ethAddressType = AddressType(1, "ETH", "+*", ".*") private val ethMemoAddressType = AddressType(2, "ETH", "+*", "+*") private val ethChain = Chain("ETH_MAINNET", arrayListOf(ethAddressType)) @@ -31,39 +36,77 @@ class AssignAddressServiceImplUnitTest { init { - val eth = CurrencyImplementation( + val eth = CryptoCurrencyCommand( currency, + UUID.randomUUID().toString(), currency, - ethChain, + true, + true, false, null, null, + BigDecimal.ZERO, + true, true, - BigDecimal.ONE, - BigDecimal.TEN, - 18 + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + 18, + ethChain.name, + null +// ethChain ) - val wrappedEth = CurrencyImplementation( + + val wrappedEth = CryptoCurrencyCommand( currency, + UUID.randomUUID().toString(), currency, - bscChain, + true, + true, false, - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "WETH", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + BigDecimal.ZERO, true, - BigDecimal.ONE, - BigDecimal.ONE, - 18 - ) + true, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + 18, + bscChain.name, + null +// bscChain - coEvery { currencyHandler.fetchCurrencyInfo(currency.symbol) } returns CurrencyInfo( - currency, - listOf(eth, wrappedEth) ) + coEvery { + currencyHandler.fetchCurrencyOnChainGateways( + FetchGateways( + currencySymbol = currency, + gatewayUuid = gatewayUuid + ) + ) + } returns + listOf(eth, wrappedEth) + + coEvery { currencyHandler.fetchCurrencyOnChainGateways(FetchGateways(currencySymbol = currency)) } returns + listOf(eth, wrappedEth) + + coEvery { chainLoader.fetchChainInfo(chain = ethChain.name) } returns ethChain + + coEvery { chainLoader.fetchChainInfo(chain = bscChain.name) } returns bscChain + + coEvery { currencyHandler.fetchOnChainGateway(currency = currency, gatewayUuid = gatewayUuid) } returns eth + + + coEvery { assignedAddressHandler.persist(any()) } returns Unit coEvery { reservedAddressHandler.remove(any()) } returns Unit + + } @Test @@ -89,7 +132,7 @@ class AssignAddressServiceImplUnitTest { ethMemoAddressType ) - val assignedAddress = assignAddressServiceImpl.assignAddress(user, currency, chain) + val assignedAddress = assignAddressServiceImpl.assignAddress(user, currency, gatewayUuid) assertThat(assignedAddress).isEqualTo( listOf( AssignedAddress( @@ -114,7 +157,7 @@ class AssignAddressServiceImplUnitTest { ) } returns emptyList() coEvery { reservedAddressHandler.peekReservedAddress(ethAddressType) } returns null - coEvery { assignAddressServiceImpl.assignAddress(user, currency, chain) } throws RuntimeException() + coEvery { assignAddressServiceImpl.assignAddress(user, currency, gatewayUuid) } throws RuntimeException() } @Test @@ -146,7 +189,7 @@ class AssignAddressServiceImplUnitTest { ethMemoAddressType ) - val assignedAddress = assignAddressServiceImpl.assignAddress(user, currency, chain) + val assignedAddress = assignAddressServiceImpl.assignAddress(user, currency, gatewayUuid) assertThat(assignedAddress).isEqualTo( listOf( AssignedAddress( diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/pom.xml b/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/pom.xml index 359b1ce63..73b162182 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/pom.xml +++ b/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/pom.xml @@ -1,6 +1,6 @@ - 4.0.0 diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/authproxy/impl/AuthProxyImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/authproxy/impl/AuthProxyImpl.kt index 553b1cd3c..5937ccb2f 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/authproxy/impl/AuthProxyImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-auth-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/authproxy/impl/AuthProxyImpl.kt @@ -5,6 +5,7 @@ import co.nilin.opex.bcgateway.core.model.otc.LoginResponse import co.nilin.opex.bcgateway.core.spi.AuthProxy import kotlinx.coroutines.reactive.awaitFirst import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Profile import org.springframework.core.ParameterizedTypeReference import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -24,14 +25,16 @@ class AuthProxyImpl(private val webClient: WebClient) : AuthProxy { override suspend fun getToken(loginRequest: LoginRequest): LoginResponse { return webClient.post() - .uri(URI.create("${baseUrl}/api/v1/login")) - .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .body(BodyInserters.fromFormData("mobile", loginRequest.clientId) - .with("password", loginRequest.clientSecret)) - .retrieve() - .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToMono(typeRef()) - .awaitFirst() + .uri(URI.create("${baseUrl}/api/v1/login")) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .body( + BodyInserters.fromFormData("mobile", loginRequest.clientId) + .with("password", loginRequest.clientSecret) + ) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono(typeRef()) + .awaitFirst() } } diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-eventlistener-kafka/pom.xml b/bc-gateway/bc-gateway-ports/bc-gateway-eventlistener-kafka/pom.xml index b9b9baa18..c60167193 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-eventlistener-kafka/pom.xml +++ b/bc-gateway/bc-gateway-ports/bc-gateway-eventlistener-kafka/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/pom.xml b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/pom.xml new file mode 100644 index 000000000..6ac89442c --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + co.nilin.opex.bcgateway + bc-gateway + 1.0.1-beta.7 + ../../pom.xml + + + co.nilin.opex.bcgateway.ports.omniwallet + bc-gateway-omniwallet-proxy + bc-gateway-omniwallet-proxy + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.springframework.boot + spring-boot-starter + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + co.nilin.opex.bcgateway.core + bc-gateway-core + + + org.springframework + spring-webflux + + + diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/impl/OmniWalletManagerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/impl/OmniWalletManagerImpl.kt new file mode 100644 index 000000000..f134aef2d --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/impl/OmniWalletManagerImpl.kt @@ -0,0 +1,31 @@ +package co.nilin.opex.bcgateway.omniwallet.impl + +import co.nilin.opex.bcgateway.core.model.CryptoCurrencyCommand +import co.nilin.opex.bcgateway.core.model.OmniBalance +import co.nilin.opex.bcgateway.core.spi.OmniWalletManager +import co.nilin.opex.bcgateway.omniwallet.model.AddressBalanceWithUsd +import co.nilin.opex.bcgateway.omniwallet.proxy.OmniWalletProxy +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.math.BigDecimal + +@Component +class OmniWalletManagerImpl(private val omniWalletProxy: OmniWalletProxy) : OmniWalletManager { + private val logger = LoggerFactory.getLogger(omniWalletProxy::class.java) + + override suspend fun getTokenBalance(cryptoCurrencyCommand: CryptoCurrencyCommand): OmniBalance { + return OmniBalance(currency = cryptoCurrencyCommand.currencySymbol, + network = cryptoCurrencyCommand.chain, + balance = omniWalletProxy.getTokenBalance(cryptoCurrencyCommand.tokenAddress!!, cryptoCurrencyCommand.chain) + ?.stream()?.map(AddressBalanceWithUsd::balance)?.reduce { a, b -> a + b }?.orElse(BigDecimal.ZERO) + ) + } + + override suspend fun getAssetBalance(cryptoCurrencyCommand: CryptoCurrencyCommand): OmniBalance { + return OmniBalance( + cryptoCurrencyCommand.currencySymbol, + cryptoCurrencyCommand.chain, + omniWalletProxy.getAssetBalance(cryptoCurrencyCommand.chain)?.balance ?: BigDecimal.ZERO + ) + } +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/model/AddressBalanceWithUsd.kt b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/model/AddressBalanceWithUsd.kt new file mode 100644 index 000000000..29f74ea17 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/model/AddressBalanceWithUsd.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.bcgateway.omniwallet.model + +import java.math.BigDecimal + +data class AddressBalanceWithUsd(val address: String, val balance: BigDecimal, val balanceUsd: BigDecimal) + + +data class ChainBalanceResponse(val data: List) \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/proxy/OmniWalletProxy.kt b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/proxy/OmniWalletProxy.kt new file mode 100644 index 000000000..fe5fd9fef --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-omniwallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/omniwallet/proxy/OmniWalletProxy.kt @@ -0,0 +1,69 @@ +package co.nilin.opex.bcgateway.omniwallet.proxy + +import co.nilin.opex.bcgateway.omniwallet.model.AddressBalanceWithUsd +import kotlinx.coroutines.reactive.awaitFirst +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import java.math.BigDecimal + +inline fun typeRef(): ParameterizedTypeReference = object : ParameterizedTypeReference() {} +data class TotalAssetByChainWithUsd( + val balance: BigDecimal, + val chain: String? = null, + val symbol: String? = null, + val balanceUsd: BigDecimal? = null +) + +@Component +class OmniWalletProxy(private val webClient: WebClient) { + + + @Value("\${app.omni-wallet.url}") + private lateinit var baseUrl: String + + private val logger: Logger = LoggerFactory.getLogger(OmniWalletProxy::class.java) + + suspend fun getAssetBalance(network: String): TotalAssetByChainWithUsd? { +// return TotalAssetByChainWithUsd(BigDecimal(15),network,"", BigDecimal(65)) +// + logger.info("----&&&&&&&&&&&----") + + return webClient.get() + .uri("${baseUrl}/v1/balance/chain/${network}/total") + { + it.queryParam("excludeZero", false) + it.build() + } + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .retrieve() + .bodyToMono(typeRef()) + .doOnError { e -> logger.info("An error happened during get balance of chain $network : ${e.message}") } + .onErrorReturn(TotalAssetByChainWithUsd(balance = BigDecimal.ZERO)) + .log() + .awaitFirst() + } + + suspend fun getTokenBalance(tokenAddress: String, network: String): List? { +// return listOf( AddressBalanceWithUsd("", BigDecimal.TEN, BigDecimal.TEN)) + return webClient.get() + .uri("${baseUrl}/v1/balance/token/address/${tokenAddress}") + { + it.queryParam("excludeZero", false) + it.build() + } + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .retrieve() + .bodyToMono(typeRef?>()) + .doOnError { e -> logger.info("An error happened during get balance of token $tokenAddress : ${e.message}") } + .onErrorReturn(listOf(AddressBalanceWithUsd(tokenAddress, BigDecimal.ZERO, BigDecimal.ZERO))) + .log() + .awaitFirst() + + } +} diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/pom.xml b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/pom.xml index ba0b4b5af..a7dbc61bd 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/pom.xml +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -55,6 +55,18 @@ reactor-test test - + + org.modelmapper + modelmapper + 3.2.0 + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.flywaydb + flyway-core + diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/config/PostgresConfig.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/config/PostgresConfig.kt index c1dae05d6..72c4ab144 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/config/PostgresConfig.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/config/PostgresConfig.kt @@ -1,24 +1,54 @@ package co.nilin.opex.bcgateway.ports.postgres.config +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile import org.springframework.core.io.Resource import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories import org.springframework.r2dbc.core.DatabaseClient @Configuration @EnableR2dbcRepositories(basePackages = ["co.nilin.opex"]) -class PostgresConfig( - db: DatabaseClient, - @Value("classpath:schema.sql") private val schemaResource: Resource -) { +class PostgresConfig { + private val logger = LoggerFactory.getLogger(PostgresConfig::class.java) + init { - val schemaReader = schemaResource.inputStream.reader() - val schema = schemaReader.readText().trim() - schemaReader.close() - val initDb = db.sql { schema } - initDb // initialize the database - .then() - .subscribe() // execute + logger.info("🔍 PostgresConfig loaded") + } + @Bean + fun flywayConfig(@Value("\${spring.datasource.url}") url: String, + @Value("\${spring.datasource.username}") user: String, + @Value("\${spring.datasource.password}") password: String): Flyway? { + + val flyway: Flyway = Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .baselineVersion("1") + .load() + try { + retry(6, 5000) { + flyway.migrate() + } + } catch (e: Exception) { + logger.error("❌ Flyway migration failed", e) + } + return flyway + } + + fun retry(times: Int, delayMs: Long, block: () -> Unit) { + var attempt = 0 + while (true) { + try { + block() + return + } catch (e: Exception) { + if (++attempt >= times) throw e + Thread.sleep(delayMs) + } + } } } diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt index 42854193a..e58e92dd5 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt @@ -17,7 +17,7 @@ interface AssignedAddressRepository : ReactiveCrudRepository, - @Param("status") status:AddressStatus?=null + @Param("status") status: AddressStatus? = null ): Flow @Query("select * from assigned_addresses where address = :address and (memo is null or memo = '' or memo = :memo)") @@ -27,18 +27,24 @@ interface AssignedAddressRepository : ReactiveCrudRepository - @Query("select * from assigned_addresses where address = :address and (memo is null or memo = '' or memo = :memo) and (:status is null or status =:status)") fun findByAddressAndMemoAndStatus( - @Param("address") address: String, - @Param("memo") memo: String?, - @Param("status") status:AddressStatus?=null + @Param("address") address: String, + @Param("memo") memo: String?, + @Param("status") status: AddressStatus? = null ): Mono @Query("select * from assigned_addresses where (:windowPoint is null or assigned_date > :windowPoint ) and (:now is null or exp_time< :now ) and (:status is null or status =:status) ") fun findPotentialExpAddress( - @Param("windowPoint") windowPont: LocalDateTime?, - @Param("now") now: LocalDateTime?, - @Param("status") status:AddressStatus?=null + @Param("windowPoint") windowPont: LocalDateTime?, + @Param("now") now: LocalDateTime?, + @Param("status") status: AddressStatus? = null ): Flow? + + @Query("select * from assigned_addresses where address = :address and (memo is null or memo = '' or memo = :memo) and assigned_date <= :at and (exp_time is null or exp_time > :at) order by assigned_date desc limit 1") + fun findHolderAt( + @Param("address") address: String, + @Param("memo") memo: String?, + @Param("at") at: LocalDateTime + ): Mono } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ChainRepository.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ChainRepository.kt index a3e8fcd06..32194b3ee 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ChainRepository.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ChainRepository.kt @@ -14,7 +14,7 @@ interface ChainRepository : ReactiveCrudRepository { @Query("insert into chains values (:name) on conflict do nothing") fun insert(name: String): Mono - fun findByName(name: String): Mono + fun findByName(name: String): Mono? @Query( """ diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyImplementationRepository.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyImplementationRepository.kt index e480905c5..00ad40987 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyImplementationRepository.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyImplementationRepository.kt @@ -1,29 +1,112 @@ package co.nilin.opex.bcgateway.ports.postgres.dao +import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayView import co.nilin.opex.bcgateway.core.model.WithdrawData -import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyImplementationModel -import kotlinx.coroutines.flow.Flow +import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayModel +import co.nilin.opex.common.data.UserLanguage import org.springframework.data.r2dbc.repository.Query import org.springframework.data.repository.reactive.ReactiveCrudRepository import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux import reactor.core.publisher.Mono -import java.math.BigDecimal @Repository -interface CurrencyImplementationRepository : ReactiveCrudRepository { +interface CurrencyImplementationRepository : ReactiveCrudRepository { - fun findByCurrencySymbol(currencySymbol: String): Flow + fun findByGatewayUuid(uuid: String): Mono? - fun findByChain(chain: String): Flow + @Query( + """ + SELECT c.id, + c.gateway_uuid, + c.currency_symbol, + c.implementation_symbol, + c.chain, + c.is_token, + c.token_address, + c.token_name, + c.withdraw_allowed, + c.deposit_allowed, + c.withdraw_fee, + c.withdraw_min, + c.withdraw_max, + c.deposit_min, + c.deposit_max, + c.decimal, + c.is_deposit_active, + c.is_withdraw_active, + cl.deposit_description, + cl.withdraw_description, + c.display_order + FROM currency_on_chain_gateway c + LEFT JOIN public.currency_on_chain_gateway_localization cl ON c.id = cl.gateway_id AND cl.language = :lang + where (:gatewayUuid is null or gateway_uuid=:gatewayUuid) + and (:currencySymbol is null or currency_symbol=:currencySymbol ) + and (:implementationSymbol is null or implementation_symbol=:implementationSymbol ) + and (:chain is null or chain=:chain ) + order by display_order + """ + ) - fun findByCurrencySymbolAndChain(currencySymbol: String, chain: String): Mono + fun findGateways( + currencySymbol: String? = null, + gatewayUuid: String? = null, + chain: String? = null, + implementationSymbol: String? = null, + lang: String? = UserLanguage.getDefaultLanguage() + ): Flux? - fun findByChainAndTokenAddress(chain: String, tokenAddress: String?): Mono + fun deleteByGatewayUuid(uuid: String): Mono - @Query(""" + @Query( + """ select withdraw_enabled as is_enabled, withdraw_fee as fee, withdraw_min as minimum - from currency_implementations + from currency_on_chain_gateway where implementation_symbol = :symbol and chain = :chain - """) + """ + ) fun findWithdrawDataBySymbolAndChain(symbol: String, chain: String): Mono -} + + fun findByCurrencySymbolAndChain(symbol: String, chain: String): Mono + + @Query( + """ + SELECT c.id, + c.gateway_uuid, + c.currency_symbol, + c.implementation_symbol, + c.chain, + c.is_token, + c.token_address, + c.token_name, + c.withdraw_allowed, + c.deposit_allowed, + c.withdraw_fee, + c.withdraw_min, + c.withdraw_max, + c.deposit_min, + c.deposit_max, + c.decimal, + c.is_deposit_active, + c.is_withdraw_active, + cl.deposit_description, + cl.withdraw_description, + c.display_order + FROM currency_on_chain_gateway c + LEFT JOIN public.currency_on_chain_gateway_localization cl ON c.id = cl.gateway_id AND cl.language = :lang + where c.gateway_uuid = :gatewayUuid + and c.currency_symbol = :symbol; + """ + ) + fun findByGatewayUuidAndCurrencySymbol( + gatewayUuid: String, + symbol: String, + lang: String? = UserLanguage.getDefaultLanguage() + ): Mono? + + @Query("select * from currency_on_chain_gateway where chain = :chain and is_token is false") + fun findMainAssetGateway(chain: String): Mono + + @Query("select * from currency_on_chain_gateway where chain = :chain and is_token is true and token_address = :tokenAddress") + fun findTokenGateway(chain: String, tokenAddress: String): Mono +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyOnChainGatewayLocalizationRepository.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyOnChainGatewayLocalizationRepository.kt new file mode 100644 index 000000000..68a1197e9 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyOnChainGatewayLocalizationRepository.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.bcgateway.ports.postgres.dao + +import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayLocalizationModel +import org.springframework.data.r2dbc.repository.Modifying +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Repository +interface CurrencyOnChainGatewayLocalizationRepository : + ReactiveCrudRepository { + @Modifying + @Query( + """ + INSERT INTO currency_on_chain_gateway_localization (gateway_id, deposit_description, withdraw_description, language) + VALUES (:gatewayId, :depositDescription, :withdrawDescription, :language) + ON CONFLICT (gateway_id, language) + DO UPDATE SET + deposit_description = :depositDescription, + withdraw_description = :withdrawDescription +""" + ) + fun upsert( + gatewayId: Long, + depositDescription: String?, + withdrawDescription: String?, + language: String + ): Mono + + suspend fun findByGatewayId(gatewayId: Long): Flux +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyRepository.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyRepository.kt index 2dd1e4517..41a6fccd1 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyRepository.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/CurrencyRepository.kt @@ -1,19 +1,13 @@ package co.nilin.opex.bcgateway.ports.postgres.dao -import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyModel -import org.springframework.data.r2dbc.repository.Query -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Mono - -@Repository -interface CurrencyRepository : ReactiveCrudRepository { - - fun findBySymbol(symbol: String): Mono - - @Query("insert into currency values (:symbol, :name) on conflict do nothing") - fun insert(name: String, symbol: String): Mono - - @Query("delete from currency where name = :name") - fun deleteByName(name: String): Mono -} +//@Repository +//interface CurrencyRepository : ReactiveCrudRepository { +// +//// fun findBySymbol(symbol: String): Mono +//// +//// @Query("insert into currency values (:symbol, :name) on conflict do nothing") +//// fun insert(name: String, symbol: String): Mono +//// +//// @Query("delete from currency where name = :name") +//// fun deleteByName(name: String): Mono +//} diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressManagerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressManagerImpl.kt index b68f382b4..f12c3c08d 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressManagerImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressManagerImpl.kt @@ -3,22 +3,23 @@ package co.nilin.opex.bcgateway.ports.postgres.impl import co.nilin.opex.bcgateway.core.model.AddressStatus import co.nilin.opex.bcgateway.core.model.ReservedAddress import co.nilin.opex.bcgateway.core.spi.AddressManager -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import java.time.LocalDateTime @Component -class AddressManagerImpl(private val addressHandlerImpl: AssignedAddressHandlerImpl, - private val reservedAddressHandlerImpl: ReservedAddressHandlerImpl) : AddressManager { +class AddressManagerImpl( + private val addressHandlerImpl: AssignedAddressHandlerImpl, + private val reservedAddressHandlerImpl: ReservedAddressHandlerImpl +) : AddressManager { private val logger = LoggerFactory.getLogger(AddressManagerImpl::class.java) override suspend fun revokeExpiredAddress() { addressHandlerImpl.fetchExpiredAssignedAddresses()?.map { addressHandlerImpl.revoke(it.apply { - id=it.id + id = it.id status = AddressStatus.Revoked - revokedDate= LocalDateTime.now() + revokedDate = LocalDateTime.now() }) reservedAddressHandlerImpl.addReservedAddress(listOf(ReservedAddress(it.address, it.memo, it.type))) diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressTypeHandlerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressTypeHandlerImpl.kt index 4fa98fc3a..833ceb32e 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressTypeHandlerImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AddressTypeHandlerImpl.kt @@ -23,4 +23,9 @@ class AddressTypeHandlerImpl(private val repository: AddressTypeRepository) : Ad repository.save(AddressTypeModel(null, name, addressRegex, memoRegex)).awaitFirstOrNull() } } + + override suspend fun fetchAddressType(name: String): AddressType? { + return repository.findByType(name) + .map { AddressType(it.id!!, it.type, it.addressRegex, it.memoRegex) }.awaitFirstOrNull() + } } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt index 3974b6c04..f820a56ae 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt @@ -19,27 +19,27 @@ import org.slf4j.Logger import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.time.LocalDateTime -import java.time.ZoneId @Service class AssignedAddressHandlerImpl( - val assignedAddressRepository: AssignedAddressRepository, - val addressTypeRepository: AddressTypeRepository, - val assignedAddressChainRepository: AssignedAddressChainRepository, - val chainLoader: ChainLoader + val assignedAddressRepository: AssignedAddressRepository, + val addressTypeRepository: AddressTypeRepository, + val assignedAddressChainRepository: AssignedAddressChainRepository, + val chainLoader: ChainLoader ) : AssignedAddressHandler { - @Value("\${app.address.life-time.value}") - private var lifeTime: Long? = null + @Value("\${app.address.life-time}") + private var addressLifeTime: Long? = null private val logger: Logger by LoggerDelegate() override suspend fun fetchAssignedAddresses(user: String, addressTypes: List): List { + addressLifeTime = 7200 if (addressTypes.isEmpty()) return emptyList() val addressTypeMap = addressTypeRepository.findAll().map { aam -> AddressType(aam.id!!, aam.type, aam.addressRegex, aam.memoRegex) }.collectMap { it.id }.awaitFirst() return assignedAddressRepository.findByUuidAndAddressTypeAndStatus( - user, addressTypes.map(AddressType::id), AddressStatus.Assigned + user, addressTypes.map(AddressType::id), AddressStatus.Assigned ).map { model -> model.toDto(addressTypeMap).apply { id = model.id } }.filter { it.expTime?.let { it > LocalDateTime.now() } ?: true }.toList() @@ -49,19 +49,19 @@ class AssignedAddressHandlerImpl( logger.info("going to save new address .............") assignedAddressRepository.save( - AssignedAddressModel( - assignedAddress.id ?: null, - assignedAddress.uuid, - assignedAddress.address, - assignedAddress.memo, - assignedAddress.type.id, - assignedAddress.id?.let { assignedAddress.expTime } - ?: (lifeTime?.let { (LocalDateTime.now().plusSeconds(lifeTime!!)) } - ?: null), - assignedAddress.id?.let { assignedAddress.assignedDate } ?: LocalDateTime.now(), - null, - assignedAddress.status - ) + AssignedAddressModel( + assignedAddress.id ?: null, + assignedAddress.uuid, + assignedAddress.address, + assignedAddress.memo, + assignedAddress.type.id, + assignedAddress.id?.let { assignedAddress.expTime } + ?: (addressLifeTime?.let { (LocalDateTime.now().plusSeconds(addressLifeTime!!)) } + ?: null), + assignedAddress.id?.let { assignedAddress.assignedDate } ?: LocalDateTime.now(), + null, + assignedAddress.status + ) ).awaitFirstOrNull() } @@ -69,23 +69,24 @@ class AssignedAddressHandlerImpl( override suspend fun revoke(assignedAddress: AssignedAddress) { assignedAddressRepository.save( - AssignedAddressModel( - assignedAddress.id, - assignedAddress.uuid, - assignedAddress.address, - assignedAddress.memo, - assignedAddress.type.id, - assignedAddress.expTime, - assignedAddress.assignedDate, - assignedAddress.revokedDate, - assignedAddress.status - ) + AssignedAddressModel( + assignedAddress.id, + assignedAddress.uuid, + assignedAddress.address, + assignedAddress.memo, + assignedAddress.type.id, + assignedAddress.expTime, + assignedAddress.assignedDate, + assignedAddress.revokedDate, + assignedAddress.status + ) ).awaitFirst() } override suspend fun findUuid(address: String, memo: String?): String? { - return assignedAddressRepository.findByAddressAndMemoAndStatus(address, memo, AddressStatus.Assigned).awaitFirstOrNull()?.uuid + return assignedAddressRepository.findByAddressAndMemoAndStatus(address, memo, AddressStatus.Assigned) + .awaitFirstOrNull()?.uuid } override suspend fun fetchExpiredAssignedAddresses(): List? { @@ -95,9 +96,9 @@ class AssignedAddressHandlerImpl( }.collectMap { it.id }.awaitFirst() //for having significant margin : (minus(5 mints) return assignedAddressRepository.findPotentialExpAddress( - (now.minusSeconds(lifeTime!!)).minusMinutes(5), - now, - AddressStatus.Assigned + (now.minusSeconds(addressLifeTime!!)).minusMinutes(5), + now, + AddressStatus.Assigned )?.filter { it.expTime != null }?.map { @@ -105,20 +106,32 @@ class AssignedAddressHandlerImpl( }?.toList() } + override suspend fun findHolder(address: String, memo: String?, at: LocalDateTime?): Pair { + + if (at == null) { + val current = assignedAddressRepository + .findByAddressAndMemoAndStatus(address, memo, AddressStatus.Assigned) + .awaitFirstOrNull() + return Pair(current?.uuid, current?.status) + } + val atModel = assignedAddressRepository.findHolderAt(address, memo, at).awaitFirstOrNull() + return Pair(atModel?.uuid, atModel?.status) + } + private suspend fun AssignedAddressModel.toDto(addressTypeMap: MutableMap): AssignedAddress { return AssignedAddress( - this.uuid, - this.address, - this.memo, - addressTypeMap.getValue(this.addressTypeId), - assignedAddressChainRepository.findByAssignedAddress(this.id!!).map { cm -> - chainLoader.fetchChainInfo(cm.chain) - }.toList().toMutableList(), - this.expTime, - this.assignedDate, - this.revokedDate, - this.status, - null + this.uuid, + this.address, + this.memo, + addressTypeMap.getValue(this.addressTypeId), + assignedAddressChainRepository.findByAssignedAddress(this.id!!).map { cm -> + chainLoader.fetchChainInfo(cm.chain) + }.toList().toMutableList(), + this.expTime, + this.assignedDate, + this.revokedDate, + this.status, + null ) } } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt index 2bde53614..c14bb6c08 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt @@ -7,14 +7,12 @@ import co.nilin.opex.bcgateway.ports.postgres.dao.AddressTypeRepository import co.nilin.opex.bcgateway.ports.postgres.dao.ChainAddressTypeRepository import co.nilin.opex.bcgateway.ports.postgres.dao.ChainRepository import co.nilin.opex.bcgateway.ports.postgres.model.ChainAddressTypeModel +import co.nilin.opex.common.OpexError import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactive.awaitFirstOrNull -import kotlinx.coroutines.reactive.awaitSingle import org.springframework.stereotype.Component -import co.nilin.opex.common.OpexError @Component class ChainHandler( @@ -24,7 +22,7 @@ class ChainHandler( ) : ChainLoader { override suspend fun addChain(name: String, addressType: String): Chain { - val chain = chainRepository.findByName(name).awaitFirstOrNull() + val chain = chainRepository.findByName(name)?.awaitFirstOrNull() if (chain != null) throw OpexError.BadRequest.exception() @@ -32,8 +30,8 @@ class ChainHandler( ?: throw OpexError.InvalidAddressType.exception() chainRepository.insert(name).awaitFirstOrNull() - val model = chainRepository.findByName(name).awaitFirst() - chainAddressRepository.save(ChainAddressTypeModel(null, model.name, type.id!!)).awaitFirstOrNull() + val model = chainRepository.findByName(name)?.awaitFirstOrElse { throw OpexError.BadRequest.exception() } + chainAddressRepository.save(ChainAddressTypeModel(null, model!!.name, type.id!!)).awaitFirstOrNull() return Chain(model.name, emptyList()) } @@ -46,15 +44,15 @@ class ChainHandler( .map { AddressType(it.id!!, it.type, it.addressRegex, it.memoRegex) } .toList() - Chain(c.name, addressTypes) + Chain(c.name, addressTypes, c.externalChainScannerUrl) } } override suspend fun fetchChainInfo(chain: String): Chain { - val chainDao = chainRepository.findByName(chain).awaitSingle() + val chainDao = chainRepository.findByName(chain)?.awaitFirstOrElse { throw OpexError.ChainNotFound.exception() } val addressTypes = chainRepository.findAddressTypesByName(chain) .map { AddressType(it.id!!, it.type, it.addressRegex, it.memoRegex) }.toList() - return Chain(chainDao.name, addressTypes) + return Chain(chainDao!!.name, addressTypes) } } diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/CurrencyHandlerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/CurrencyHandlerImpl.kt deleted file mode 100644 index f19a84256..000000000 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/CurrencyHandlerImpl.kt +++ /dev/null @@ -1,246 +0,0 @@ -package co.nilin.opex.bcgateway.ports.postgres.impl - -import co.nilin.opex.bcgateway.core.model.* -import co.nilin.opex.bcgateway.core.spi.CurrencyHandler -import co.nilin.opex.bcgateway.ports.postgres.dao.ChainRepository -import co.nilin.opex.bcgateway.ports.postgres.dao.CurrencyImplementationRepository -import co.nilin.opex.bcgateway.ports.postgres.dao.CurrencyRepository -import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyImplementationModel -import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyModel -import co.nilin.opex.common.OpexError -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.reactive.awaitFirst -import kotlinx.coroutines.reactive.awaitFirstOrElse -import kotlinx.coroutines.reactive.awaitFirstOrNull -import kotlinx.coroutines.reactive.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import java.math.BigDecimal - -@Component -class CurrencyHandlerImpl( - private val chainRepository: ChainRepository, - private val currencyRepository: CurrencyRepository, - private val currencyImplementationRepository: CurrencyImplementationRepository -) : CurrencyHandler { - - private val logger = LoggerFactory.getLogger(CurrencyHandler::class.java) - - override suspend fun addCurrency(name: String, symbol: String) { - try { - currencyRepository.insert(name, symbol.uppercase()).awaitSingleOrNull() - } catch (e: Exception) { - logger.error("Could not insert new currency $name", e) - } - } - - override suspend fun addCurrencyImplementationV2( - currencySymbol: String, - implementationSymbol: String, - currencyName: String, - chain: String, - tokenName: String?, - tokenAddress: String?, - isToken: Boolean, - withdrawFee: BigDecimal, - minimumWithdraw: BigDecimal, - isWithdrawEnabled: Boolean, - decimal: Int - ): CurrencyImplementation { - currencyRepository.findBySymbol(currencySymbol).awaitFirstOrNull()?.let { - throw OpexError.CurrencyIsExist.exception() - } ?: run { - addCurrency(currencyName, currencySymbol) - return addCurrencyImplementation( - currencySymbol, - implementationSymbol, - chain, - tokenName, - tokenAddress, - isToken, - withdrawFee, - minimumWithdraw, - isWithdrawEnabled, - decimal - ) - } - } - - override suspend fun updateCurrencyImplementation( - currencySymbol: String, - implementationSymbol: String, - currencyName: String, - newChain: String?, - tokenName: String?, - tokenAddress: String?, - isToken: Boolean, - withdrawFee: BigDecimal, - minimumWithdraw: BigDecimal, - isWithdrawEnabled: Boolean, - decimal: Int, - oldChain: String - ): CurrencyImplementation? { - currencyRepository.findBySymbol(currencySymbol).awaitFirstOrNull()?.let { cm -> - currencyRepository.save(CurrencyModel(currencySymbol, currencyName)).awaitSingleOrNull() - return currencyImplementationRepository.findByCurrencySymbolAndChain(currencySymbol, oldChain) - ?.awaitSingleOrNull() - ?.let { - it.apply { - this.implementationSymbol = implementationSymbol - this.chain = newChain ?: oldChain - this.decimal = decimal - this.token = isToken - this.tokenAddress = tokenAddress - this.tokenName = tokenName - this.withdrawEnabled = isWithdrawEnabled - this.withdrawFee = withdrawFee - this.withdrawMin = minimumWithdraw - } - currencyImplementationRepository.save(it).awaitSingleOrNull() - ?.let { icm -> projectCurrencyImplementation(icm, cm) } - } - - } ?: throw OpexError.CurrencyNotFound.exception() - } - - override suspend fun editCurrency(name: String, symbol: String) { - val currency = currencyRepository.findBySymbol(symbol).awaitFirstOrNull() - if (currency != null) { - currency.name = name - currencyRepository.save(currency).awaitFirst() - } - } - - override suspend fun deleteCurrency(name: String) { - try { - currencyRepository.deleteByName(name).awaitFirstOrNull() - } catch (e: Exception) { - logger.error("Could not delete currency $name", e) - } - } - - override suspend fun addCurrencyImplementation( - currencySymbol: String, - implementationSymbol: String, - chain: String, - tokenName: String?, - tokenAddress: String?, - isToken: Boolean, - withdrawFee: BigDecimal, - minimumWithdraw: BigDecimal, - isWithdrawEnabled: Boolean, - decimal: Int - ): CurrencyImplementation { - val chainModel = chainRepository.findByName(chain).awaitFirstOrNull() - ?: throw OpexError.ChainNotFound.exception() - - currencyImplementationRepository.findByCurrencySymbolAndChain(currencySymbol.uppercase(), chain) - .awaitFirstOrNull() - ?.let { throw OpexError.DuplicateToken.exception() } - - val currency = currencyRepository.findBySymbol(currencySymbol.uppercase()).awaitFirstOrNull() - ?: throw OpexError.CurrencyNotFoundBC.exception() - - val model = currencyImplementationRepository.save( - CurrencyImplementationModel( - null, - currencySymbol.uppercase(), - implementationSymbol, - chainModel.name, - isToken, - tokenAddress, - tokenName, - isWithdrawEnabled, - withdrawFee, - minimumWithdraw, - decimal - ) - ).awaitFirst() - - logger.info("Add currency implementation: ${model.currencySymbol} - ${model.chain}") - - return projectCurrencyImplementation(model, currency) - } - - override suspend fun fetchAllImplementations(): List { - return currencyImplementationRepository.findAll() - .collectList() - .awaitFirstOrElse { emptyList() } - .map { - val currency = currencyRepository.findBySymbol(it.currencySymbol).awaitFirstOrNull() - projectCurrencyImplementation(it, currency) - } - } - - override suspend fun fetchCurrencyInfo(symbol: String): CurrencyInfo { - val symbolUpperCase = symbol.uppercase() - val currencyModel = currencyRepository.findBySymbol(symbolUpperCase).awaitSingleOrNull() - if (currencyModel === null) { - return CurrencyInfo(Currency("", symbolUpperCase), emptyList()) - } - val currencyImplModel = currencyImplementationRepository.findByCurrencySymbol(symbolUpperCase) - val currency = Currency(currencyModel.symbol, currencyModel.name) - val implementations = currencyImplModel.map { projectCurrencyImplementation(it, currencyModel) } - return CurrencyInfo(currency, implementations.toList()) - } - - override suspend fun findByChainAndTokenAddress(chain: String, address: String?): CurrencyImplementation? { - val impl = currencyImplementationRepository.findByChainAndTokenAddress(chain, address) - .awaitFirstOrNull() - - return if (impl != null) - projectCurrencyImplementation(impl) - else - null - } - - override suspend fun findImplementationsWithTokenOnChain(chain: String): List { - return currencyImplementationRepository.findByChain(chain).map { projectCurrencyImplementation(it) }.toList() - } - - override suspend fun findImplementationsByCurrency(currency: String): List { - return currencyImplementationRepository.findByCurrencySymbol(currency) - .map { projectCurrencyImplementation(it) } - .toList() - } - - override suspend fun changeWithdrawStatus(symbol: String, chain: String, status: Boolean) { - val impl = currencyImplementationRepository.findByCurrencySymbolAndChain(symbol, chain).awaitSingleOrNull() - ?: throw OpexError.TokenNotFound.exception() - - impl.apply { - withdrawEnabled = status - currencyImplementationRepository.save(impl).awaitFirstOrNull() - } - } - - override suspend fun getWithdrawData(symbol: String, network: String): WithdrawData { - return currencyImplementationRepository.findWithdrawDataBySymbolAndChain(symbol, network) - .awaitSingleOrNull() ?: throw OpexError.CurrencyNotFound.exception() - } - - private suspend fun projectCurrencyImplementation( - currencyImplementationModel: CurrencyImplementationModel, - currencyModel: CurrencyModel? = null - ): CurrencyImplementation { - val addressTypesModel = chainRepository.findAddressTypesByName(currencyImplementationModel.chain) - val addressTypes = - addressTypesModel.map { AddressType(it.id!!, it.type, it.addressRegex, it.memoRegex) }.toList() - val currencyModelVal = - currencyModel ?: currencyRepository.findBySymbol(currencyImplementationModel.currencySymbol).awaitSingle() - return CurrencyImplementation( - Currency(currencyModelVal.symbol, currencyModelVal.name), - Currency(currencyImplementationModel.implementationSymbol, currencyModelVal.name), - Chain(currencyImplementationModel.chain, addressTypes), - currencyImplementationModel.token, - currencyImplementationModel.tokenAddress, - currencyImplementationModel.tokenName, - currencyImplementationModel.withdrawEnabled, - currencyImplementationModel.withdrawFee, - currencyImplementationModel.withdrawMin, - currencyImplementationModel.decimal - ) - } -} diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/CurrencyHandlerImplV2.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/CurrencyHandlerImplV2.kt new file mode 100644 index 000000000..74c781798 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/CurrencyHandlerImplV2.kt @@ -0,0 +1,214 @@ +package co.nilin.opex.bcgateway.ports.postgres.impl + +import co.nilin.opex.bcgateway.core.model.* +import co.nilin.opex.bcgateway.core.spi.CryptoCurrencyHandlerV2 +import co.nilin.opex.bcgateway.ports.postgres.dao.ChainRepository +import co.nilin.opex.bcgateway.ports.postgres.dao.CurrencyImplementationRepository +import co.nilin.opex.bcgateway.ports.postgres.dao.CurrencyOnChainGatewayLocalizationRepository +import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayLocalizationModel +import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayModel +import co.nilin.opex.bcgateway.ports.postgres.util.toCommand +import co.nilin.opex.bcgateway.ports.postgres.util.toDto +import co.nilin.opex.bcgateway.ports.postgres.util.toModel +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.data.UserLanguage +import co.nilin.opex.common.utils.LanguageUtils.getDefaultUserLanguage +import co.nilin.opex.common.utils.LanguageUtils.getUserLanguage +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.transaction.reactive.TransactionalOperator +import org.springframework.transaction.reactive.executeAndAwait +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.stream.Collectors + +@Component +class CurrencyHandlerImplV2( + private val chainRepository: ChainRepository, + private val currencyImplementationRepository: CurrencyImplementationRepository, + private val currencyOnChainGatewayLocalizationRepository: CurrencyOnChainGatewayLocalizationRepository, + private val transactionalOperator: TransactionalOperator +) : CryptoCurrencyHandlerV2 { + + private val logger = LoggerFactory.getLogger(CurrencyHandlerImplV2::class.java) + + override suspend fun createOnChainGateway(request: CryptoCurrencyCommand): CryptoCurrencyCommand? { + chainRepository.findByName(request.chain) + ?.awaitFirstOrElse { throw OpexError.ChainNotFound.exception() } + currencyImplementationRepository.findGateways( + currencySymbol = request.currencySymbol, + chain = request.chain, + implementationSymbol = request.implementationSymbol + ) + ?.awaitFirstOrNull()?.let { throw OpexError.GatewayIsExist.exception() } + return doSave(request)?.toDto(); + } + + override suspend fun updateOnChainGateway(request: CryptoCurrencyCommand): CryptoCurrencyCommand? { + return loadImpls(FetchGateways(gatewayUuid = request.gatewayUuid, currencySymbol = request.currencySymbol)) + ?.awaitFirstOrElse { throw OpexError.GatewayNotFount.exception() }?.let { oldGateway -> + doUpdate(oldGateway.toDto().updateTo(request).toModel().apply { id = oldGateway.id })?.toDto() + } + } + + override suspend fun deleteOnChainGateway(gatewayUuid: String, currency: String): Void? { + + loadImpls(FetchGateways(gatewayUuid = gatewayUuid, currencySymbol = currency)) + ?.awaitFirstOrElse { throw OpexError.GatewayNotFount.exception() }?.let { + try { + return currencyImplementationRepository.deleteByGatewayUuid(gatewayUuid)?.awaitFirstOrNull() + } catch (e: Exception) { + throw OpexError.BadRequest.exception() + + } + } + return null + } + + override suspend fun fetchCurrencyOnChainGateways(data: FetchGateways?): List? { + logger.info("going to fetch impls of ${data?.currencySymbol ?: "all currencies"}") + return loadImpls( + data, + UserLanguage.safeValueOf(getUserLanguage().awaitSingleOrNull()).toString() + )?.map { it.toDto() } + ?.collect(Collectors.toList())?.awaitFirstOrNull() + } + + override suspend fun fetchOnChainGateway(gatewayUuid: String, symbol: String): CryptoCurrencyCommand? { + return loadImpl( + gatewayUuid, + symbol, + UserLanguage.safeValueOf(getUserLanguage().awaitSingleOrNull()).toString() + )?.awaitFirstOrNull()?.toDto() + } + + private suspend fun loadImpls( + request: FetchGateways?, + language: String? = null + ): Flux? { + var resp = currencyImplementationRepository.findGateways( + request?.currencySymbol, + request?.gatewayUuid, + request?.chain, + request?.currencyImplementationName, + language + ) + return resp + ?: throw OpexError.ImplNotFound.exception() + } + + private suspend fun loadImpl( + gateway: String, + symbol: String, + language: String + ): Mono? { + return currencyImplementationRepository.findByGatewayUuidAndCurrencySymbol( + gateway, + symbol, + language + ) + ?: throw OpexError.ImplNotFound.exception() + } + + private suspend fun doSave(request: CryptoCurrencyCommand): CurrencyOnChainGatewayView? { + return transactionalOperator.executeAndAwait { + + val gateway = currencyImplementationRepository.save(request.toModel()).awaitSingleOrNull() + if (gateway == null) { + return@executeAndAwait null + } + + if (!request.depositDescription.isNullOrEmpty() || !request.withdrawDescription.isNullOrEmpty()) { + currencyOnChainGatewayLocalizationRepository.save( + CurrencyOnChainGatewayLocalizationModel( + gatewayId = gateway.id!!, + depositDescription = request.depositDescription, + withdrawDescription = request.withdrawDescription, + language = getDefaultUserLanguage() + ) + ).awaitSingleOrNull() + } + loadImpl(gateway.gatewayUuid, gateway.currencySymbol, getDefaultUserLanguage())?.awaitFirstOrNull() + + } + } + + private suspend fun doUpdate(request: CurrencyOnChainGatewayModel): CurrencyOnChainGatewayView? { + val gateway = currencyImplementationRepository.save(request).awaitSingleOrNull() ?: return null + return loadImpl(gateway.gatewayUuid, gateway.currencySymbol, getDefaultUserLanguage())?.awaitFirstOrNull() + } + + override suspend fun changeWithdrawStatus(symbol: String, chain: String, status: Boolean) { + val onChainGateway = + currencyImplementationRepository.findByCurrencySymbolAndChain(symbol, chain).awaitSingleOrNull() + ?: throw OpexError.TokenNotFound.exception() + + onChainGateway.apply { + withdrawAllowed = status + currencyImplementationRepository.save(onChainGateway).awaitFirstOrNull() + } + } + + override suspend fun getWithdrawData(symbol: String, network: String): WithdrawData { + return currencyImplementationRepository.findWithdrawDataBySymbolAndChain(symbol, network) + .awaitSingleOrNull() ?: throw OpexError.CurrencyNotFound.exception() + } + + override suspend fun fetchGatewayWithoutSymbol( + chain: String, + isToken: Boolean, + tokenAddress: String? + ): CryptoCurrencyCommand? { + chainRepository.findByName(chain)?.awaitFirstOrElse { throw OpexError.ChainNotFound.exception() } + + return if (isToken) + currencyImplementationRepository.findTokenGateway(chain, tokenAddress!!).awaitSingleOrNull()?.toDto() + else + currencyImplementationRepository.findMainAssetGateway(chain).awaitSingleOrNull()?.toDto() + } + + override suspend fun saveOnChainGatewayLocalization( + gatewayUuid: String, + localizations: List + ): List { + return transactionalOperator.executeAndAwait { + + val gateway = currencyImplementationRepository.findByGatewayUuid(gatewayUuid)?.awaitSingleOrNull() + ?: throw OpexError.GatewayNotFount.exception() + + localizations.forEach { g -> + if (!g.depositDescription.isNullOrBlank() || !g.withdrawDescription.isNullOrBlank()) + currencyOnChainGatewayLocalizationRepository.upsert( + gatewayId = gateway.id!!, + depositDescription = g.depositDescription, + withdrawDescription = g.withdrawDescription, + language = UserLanguage.safeValueOf(g.language).toString() + ).awaitSingleOrNull() + } + + currencyOnChainGatewayLocalizationRepository.findByGatewayId(gateway.id!!) + .map { it.toCommand() } + .collectList() + .awaitSingleOrNull() + ?: emptyList() + } ?: throw OpexError.BadRequest.exception("Failed to save gateway localizations") + } + + + override suspend fun fetchOnChainGatewayLocalizations(gatewayUuid: String): List { + val gateway = currencyImplementationRepository.findByGatewayUuid(gatewayUuid)?.awaitSingleOrNull() + ?: throw OpexError.GatewayNotFount.exception() + return currencyOnChainGatewayLocalizationRepository.findByGatewayId(gateway.id!!) + .map { it.toCommand() } + .collectList() + .awaitSingleOrNull() + ?: emptyList() + } + + override suspend fun deleteOnChainGatewayLocalizations(id: Long) { + currencyOnChainGatewayLocalizationRepository.deleteById(id).awaitSingleOrNull() + } +} diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/DepositHandlerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/DepositHandlerImpl.kt index 9e475a2f3..839f2ecba 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/DepositHandlerImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/DepositHandlerImpl.kt @@ -7,15 +7,15 @@ import co.nilin.opex.bcgateway.ports.postgres.model.DepositModel import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull import org.springframework.stereotype.Component @Component class DepositHandlerImpl(private val depositRepository: DepositRepository) : DepositHandler { + override suspend fun findDepositsByHash(hash: List): List { return depositRepository.findAllByHash(hash).map { Deposit( - it.id, it.hash, it.depositor, it.depositorMemo, it.amount, it.chain, it.token, it.tokenAddress + it.id, it.hash, it.depositor, it.depositorMemo, it.amount, it.chain, it.token, it.tokenAddress ) }.toList() } @@ -23,11 +23,24 @@ class DepositHandlerImpl(private val depositRepository: DepositRepository) : Dep override suspend fun saveAll(deposits: List) { depositRepository.saveAll(deposits.map { DepositModel( - null, it.hash, it.depositor, it.depositorMemo, it.amount, it.chain, it.token, it.tokenAddress + null, it.hash, it.depositor, it.depositorMemo, it.amount, it.chain, it.token, it.tokenAddress ) }).collectList().awaitSingle() } - + override suspend fun save(deposit: Deposit) { + depositRepository.save( + DepositModel( + null, + deposit.hash, + deposit.depositor, + deposit.depositorMemo, + deposit.amount, + deposit.chain, + deposit.token, + deposit.tokenAddress + ) + ).awaitSingle() + } } diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/AssignedAddressModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/AssignedAddressModel.kt index 3494e4c79..f35480806 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/AssignedAddressModel.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/AssignedAddressModel.kt @@ -8,16 +8,16 @@ import java.time.LocalDateTime @Table("assigned_addresses") data class AssignedAddressModel( - @Id val id: Long?, - val uuid: String, - val address: String, - val memo: String?, - @Column("addr_type_id") val addressTypeId: Long, - @Column("exp_time") val expTime: LocalDateTime?=null, - @Column("assigned_Date") val assignedDate: LocalDateTime?=null, - @Column("revoked_Date") val revokedDate: LocalDateTime?=null, - val status: AddressStatus?=null, + @Id val id: Long?, + val uuid: String, + val address: String, + val memo: String?, + @Column("addr_type_id") val addressTypeId: Long, + @Column("exp_time") val expTime: LocalDateTime? = null, + @Column("assigned_Date") val assignedDate: LocalDateTime? = null, + @Column("revoked_Date") val revokedDate: LocalDateTime? = null, + val status: AddressStatus? = null, -) + ) diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt index 5dbe18518..9c3d8c4b8 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt @@ -4,4 +4,4 @@ import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Table @Table("chains") -data class ChainModel(@Id val name: String) +data class ChainModel(@Id val name: String, val externalChainScannerUrl: String?) diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyImplementationModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyImplementationModel.kt deleted file mode 100644 index 4a17aa2b6..000000000 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyImplementationModel.kt +++ /dev/null @@ -1,22 +0,0 @@ -package co.nilin.opex.bcgateway.ports.postgres.model - - -import org.springframework.data.annotation.Id -import org.springframework.data.relational.core.mapping.Column -import org.springframework.data.relational.core.mapping.Table -import java.math.BigDecimal - -@Table("currency_implementations") -class CurrencyImplementationModel( - @Id var id: Long?, - @Column("currency_symbol") val currencySymbol: String, - @Column("implementation_symbol") var implementationSymbol: String, - @Column("chain") var chain: String, - @Column("token") var token: Boolean, - @Column("token_address") var tokenAddress: String?, - @Column("token_name") var tokenName: String?, - @Column("withdraw_enabled") var withdrawEnabled: Boolean, - @Column("withdraw_fee") var withdrawFee: BigDecimal, - @Column("withdraw_min") var withdrawMin: BigDecimal, - @Column("decimal") var decimal: Int -) diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyOnChainGatewayLocalizationModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyOnChainGatewayLocalizationModel.kt new file mode 100644 index 000000000..66f61a0a2 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyOnChainGatewayLocalizationModel.kt @@ -0,0 +1,19 @@ +package co.nilin.opex.bcgateway.ports.postgres.model + + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("currency_on_chain_gateway_localization") +data class CurrencyOnChainGatewayLocalizationModel( + @Id var id: Long? = null, + var gatewayId: Long, + var depositDescription: String? = null, + var withdrawDescription: String? = null, + var language: String + +) + + + + diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyOnChainGatewayModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyOnChainGatewayModel.kt new file mode 100644 index 000000000..537c5c86c --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/CurrencyOnChainGatewayModel.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.bcgateway.ports.postgres.model + + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal + +@Table("currency_on_chain_gateway") +class CurrencyOnChainGatewayModel( + @Id var id: Long?, + @Column("gateway_uuid") val gatewayUuid: String, + @Column("currency_symbol") val currencySymbol: String, + @Column("implementation_symbol") var implementationSymbol: String? = currencySymbol, + @Column("chain") var chain: String, + @Column("is_token") var isToken: Boolean? = false, + @Column("token_address") var tokenAddress: String? = null, + @Column("token_name") var tokenName: String? = null, + @Column("withdraw_allowed") var withdrawAllowed: Boolean, + @Column("deposit_allowed") var depositAllowed: Boolean, + @Column("withdraw_fee") var withdrawFee: BigDecimal, + @Column("withdraw_min") var withdrawMin: BigDecimal? = BigDecimal.ZERO, + @Column("withdraw_max") var withdrawMax: BigDecimal? = BigDecimal.ZERO, + @Column("deposit_min") var depositMin: BigDecimal? = BigDecimal.ZERO, + @Column("deposit_max") var depositMax: BigDecimal? = BigDecimal.ZERO, + @Column("decimal") var decimal: Int, + @Column("is_deposit_active") var isDepositActive: Boolean? = true, + @Column("is_withdraw_active") var isWithdrawActive: Boolean? = true, + @Column("display_order") val displayOrder: Int? = null, + + ) + + + + diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/DepositModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/DepositModel.kt index 6b6c411a2..070f9b746 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/DepositModel.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/DepositModel.kt @@ -4,7 +4,6 @@ import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Column import org.springframework.data.relational.core.mapping.Table import java.math.BigDecimal -import java.time.LocalDateTime @Table("deposits") data class DepositModel( diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/NewCurrencyImplementationModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/NewCurrencyImplementationModel.kt new file mode 100644 index 000000000..2cd08b2ce --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/NewCurrencyImplementationModel.kt @@ -0,0 +1,28 @@ +//package co.nilin.opex.bcgateway.ports.postgres.model +// +// +//import org.springframework.data.annotation.Id +//import org.springframework.data.relational.core.mapping.Column +//import org.springframework.data.relational.core.mapping.Table +//import java.math.BigDecimal +//import java.util.* +// +//@Table("new_currency_implementations") +//class NewCurrencyImplementationModel( +// @Id var id: Long?, +// @Column("currency_uuid") val currencyUuid: String, +// //todo unique +// @Column("uuid") val currencyImplUuid: String, +// @Column("implementation_symbol") var implementationSymbol: String, +// @Column("chain") var chain: String, +// @Column("is_token") var isToken: Boolean?=false, +// @Column("is_active") var isActive: Boolean?=true, +// @Column("token_address") var tokenAddress: String?, +// @Column("token_name") var tokenName: String?, +// @Column("withdraw_is_enable") var withdrawIsEnable: Boolean?=true, +// @Column("withdraw_fee") var withdrawFee: BigDecimal, +// @Column("withdraw_min") var withdrawMin: BigDecimal, +// @Column("decimal") var decimal: Int, +// @Column("deposit_is_enable") var depositIsEnable: Boolean? = true +// +//) diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/util/Convertor.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/util/Convertor.kt new file mode 100644 index 000000000..2e6fa1d4a --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/util/Convertor.kt @@ -0,0 +1,94 @@ +package co.nilin.opex.bcgateway.ports.postgres.util + +import co.nilin.opex.bcgateway.core.model.CryptoCurrencyCommand +import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayLocalizationCommand +import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayView +import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayLocalizationModel +import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayModel + + +fun CryptoCurrencyCommand.toModel(): CurrencyOnChainGatewayModel { + return CurrencyOnChainGatewayModel( + null, gatewayUuid!!, + currencySymbol, + implementationSymbol, + chain, + isToken, + tokenAddress, + tokenName, + withdrawAllowed!!, + depositAllowed!!, + withdrawFee!!, + withdrawMin, + withdrawMax, + depositMin, + depositMax, + decimal, + isDepositActive, + isWithdrawActive, + displayOrder, + ) +} + +fun CurrencyOnChainGatewayModel.toDto(): CryptoCurrencyCommand { + + return CryptoCurrencyCommand( + currencySymbol, + gatewayUuid!!, + implementationSymbol, + isDepositActive, + isWithdrawActive, + isToken, + tokenName, + tokenAddress, + withdrawFee, + withdrawAllowed, + depositAllowed, + withdrawMin, + withdrawMax, + depositMin, + depositMax, + decimal, + chain, + null,//todo + null,//todo + displayOrder, + ) + +} + +fun CurrencyOnChainGatewayView.toDto(): CryptoCurrencyCommand { + + return CryptoCurrencyCommand( + currencySymbol, + gatewayUuid, + implementationSymbol, + isDepositActive, + isWithdrawActive, + isToken, + tokenName, + tokenAddress, + withdrawFee, + withdrawAllowed, + depositAllowed, + withdrawMin, + withdrawMax, + depositMin, + depositMax, + decimal, + chain, + depositDescription, + withdrawDescription, + displayOrder, + ) + +} + +fun CurrencyOnChainGatewayLocalizationModel.toCommand(): CurrencyOnChainGatewayLocalizationCommand { + return CurrencyOnChainGatewayLocalizationCommand( + id = id, + depositDescription = depositDescription, + withdrawDescription = withdrawDescription, + language = language + ) +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 000000000..d762a3f5b --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1 @@ +CREATE TABLE IF NOT EXISTS test(id SERIAL PRIMARY KEY); \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql new file mode 100644 index 000000000..e57efd927 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql @@ -0,0 +1,132 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS address_types +( + id SERIAL PRIMARY KEY, + address_type VARCHAR(20) NOT NULL, + address_regex VARCHAR(72) NOT NULL, + memo_regex VARCHAR(72) +); + +CREATE TABLE IF NOT EXISTS assigned_addresses +( + id SERIAL PRIMARY KEY, + uuid VARCHAR(72) NOT NULL, + address VARCHAR(72) NOT NULL, + memo VARCHAR(72) NOT NULL, + addr_type_id INTEGER NOT NULL REFERENCES address_types (id), + assigned_date TIMESTAMP, + revoked_date TIMESTAMP, + status VARCHAR(25), + exp_time TIMESTAMP, + UNIQUE (address, memo, exp_time) +); + + + +CREATE TABLE IF NOT EXISTS reserved_addresses +( + id SERIAL PRIMARY KEY, + address VARCHAR(72) NOT NULL, + memo VARCHAR(72) NOT NULL, + address_type INTEGER NOT NULL REFERENCES address_types (id), + UNIQUE (address, memo) +); + +CREATE TABLE IF NOT EXISTS chains +( + name VARCHAR(72) PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS assigned_address_chains +( + id SERIAL PRIMARY KEY, + assigned_address_id INTEGER NOT NULL REFERENCES assigned_addresses (id), + chain VARCHAR(72) NOT NULL REFERENCES chains (name) +); + +CREATE TABLE IF NOT EXISTS chain_address_types +( + id SERIAL PRIMARY KEY, + chain_name VARCHAR(72) NOT NULL REFERENCES chains (name), + addr_type_id INTEGER NOT NULL REFERENCES address_types (id), + UNIQUE (chain_name, addr_type_id) +); + + + +CREATE TABLE IF NOT EXISTS currency_on_chain_gateway +( + id SERIAL PRIMARY KEY, + currency_symbol VARCHAR(72) NOT NULL, + implementation_symbol VARCHAR(72) NOT NULL, + gateway_uuid VARCHAR(256) NOT NULL UNIQUE DEFAULT uuid_generate_v4(), + chain VARCHAR(72) NOT NULL REFERENCES chains (name), + is_token BOOLEAN NOT NULL, + token_address VARCHAR(72), + token_name VARCHAR(72), + withdraw_allowed BOOLEAN NOT NULL, + deposit_allowed BOOLEAN NOT NULL, + withdraw_fee DECIMAL NOT NULL, + withdraw_min DECIMAL NOT NULL, + withdraw_max DECIMAL NOT NULL, + deposit_min DECIMAL NOT NULL, + deposit_max DECIMAL NOT NULL, + decimal INTEGER NOT NULL, + is_deposit_active BOOLEAN NOT NULL DEFAULT TRUE, + is_withdraw_active BOOLEAN NOT NULL DEFAULT TRUE, + deposit_description TEXT, + withdraw_description TEXT, + display_order INTEGER, + UNIQUE (currency_symbol, chain, implementation_symbol) +); +ALTER TABLE currency_on_chain_gateway + add COLUMN IF NOT EXISTS deposit_description TEXT; + + +ALTER TABLE currency_on_chain_gateway + add COLUMN IF NOT EXISTS withdraw_description TEXT; + +ALTER TABLE currency_on_chain_gateway + drop COLUMN IF EXISTS description; + + +CREATE TABLE IF NOT EXISTS deposits +( + id SERIAL PRIMARY KEY, + hash VARCHAR(100) UNIQUE NOT NULL, + chain VARCHAR(72) NOT NULL REFERENCES chains (name), + token BOOLEAN NOT NULL, + token_address VARCHAR(72), + amount DECIMAL NOT NULL, + depositor VARCHAR(72) NOT NULL, + depositor_memo VARCHAR(72) +); + +DO +$$ +BEGIN + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'currency_on_chain_gateway' AND column_name = 'is_deposit_active') THEN ALTER TABLE currency_on_chain_gateway + ADD COLUMN is_deposit_active Boolean NOT NULL DEFAULT TRUE; + END IF; + IF EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'currency_on_chain_gateway' AND column_name = 'is_active') THEN ALTER TABLE currency_on_chain_gateway + RENAME COLUMN is_active TO is_withdraw_active; + END IF; + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'currency_on_chain_gateway' AND column_name = 'description') THEN ALTER TABLE currency_on_chain_gateway + ADD COLUMN description TEXT; + END IF; + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'currency_on_chain_gateway' AND column_name = 'display_order') THEN ALTER TABLE currency_on_chain_gateway + ADD COLUMN display_order INTEGER; + END IF; +END +$$; + + diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V3__add_chain_meta_data.sql b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V3__add_chain_meta_data.sql new file mode 100644 index 000000000..63d7da593 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V3__add_chain_meta_data.sql @@ -0,0 +1,2 @@ +Alter table chains add column external_chain_scanner_url VARCHAR(150); +Alter table chains add column address_regex VARCHAR(250); diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V4__drop_address_regx_from_chain_table.sql b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V4__drop_address_regx_from_chain_table.sql new file mode 100644 index 000000000..8f8185f28 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V4__drop_address_regx_from_chain_table.sql @@ -0,0 +1 @@ +Alter table chains drop column address_regex; diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V5__add_gateway_localization_table.sql b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V5__add_gateway_localization_table.sql new file mode 100644 index 000000000..0bdce6c8f --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V5__add_gateway_localization_table.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS currency_on_chain_gateway_localization +( + id SERIAL PRIMARY KEY, + gateway_id BIGINT NOT NULL REFERENCES currency_on_chain_gateway (id) ON DELETE CASCADE, + deposit_description TEXT, + withdraw_description TEXT, + language VARCHAR(10) NOT NULL, + UNIQUE (gateway_id, language) +); + +INSERT INTO currency_on_chain_gateway_localization (gateway_id, + deposit_description, + withdraw_description, + language) +SELECT id, deposit_description, withdraw_description, 'EN' +FROM currency_on_chain_gateway; + +ALTER TABLE currency_on_chain_gateway + DROP COLUMN deposit_description, + DROP COLUMN withdraw_description; \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/schema.sql b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/schema.sql deleted file mode 100644 index 2bbbb39af..000000000 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/schema.sql +++ /dev/null @@ -1,95 +0,0 @@ -CREATE TABLE IF NOT EXISTS address_types -( - id SERIAL PRIMARY KEY, - address_type VARCHAR(20) NOT NULL, - address_regex VARCHAR(72) NOT NULL, - memo_regex VARCHAR(72) -); - -CREATE TABLE IF NOT EXISTS assigned_addresses -( - id SERIAL PRIMARY KEY, - uuid VARCHAR(72) NOT NULL, - address VARCHAR(72) NOT NULL, - memo VARCHAR(72) NOT NULL, - addr_type_id INTEGER NOT NULL REFERENCES address_types (id), - assigned_date TIMESTAMP, - revoked_date TIMESTAMP, - status VARCHAR(25), - exp_time TIMESTAMP, - UNIQUE (address, memo, exp_time) -); - -ALTER TABLE assigned_addresses ADD COLUMN IF NOT EXISTS assigned_date TIMESTAMP; -ALTER TABLE assigned_addresses ADD COLUMN IF NOT EXISTS revoked_date TIMESTAMP; -ALTER TABLE assigned_addresses ADD COLUMN IF NOT EXISTS exp_time TIMESTAMP; -ALTER TABLE assigned_addresses ADD COLUMN IF NOT EXISTS status VARCHAR(25); - - - - -CREATE TABLE IF NOT EXISTS reserved_addresses -( - id SERIAL PRIMARY KEY, - address VARCHAR(72) NOT NULL, - memo VARCHAR(72) NOT NULL, - address_type INTEGER NOT NULL REFERENCES address_types (id), - UNIQUE (address, memo) -); - -CREATE TABLE IF NOT EXISTS chains -( - name VARCHAR(72) PRIMARY KEY -); - -CREATE TABLE IF NOT EXISTS assigned_address_chains -( - id SERIAL PRIMARY KEY, - assigned_address_id INTEGER NOT NULL REFERENCES assigned_addresses (id), - chain VARCHAR(72) NOT NULL REFERENCES chains (name) -); - -CREATE TABLE IF NOT EXISTS chain_address_types -( - id SERIAL PRIMARY KEY, - chain_name VARCHAR(72) NOT NULL REFERENCES chains (name), - addr_type_id INTEGER NOT NULL REFERENCES address_types (id), - UNIQUE (chain_name, addr_type_id) -); - -CREATE TABLE IF NOT EXISTS currency -( - symbol VARCHAR(72) PRIMARY KEY, - name VARCHAR(72) NOT NULL -); - -CREATE TABLE IF NOT EXISTS currency_implementations -( - id SERIAL PRIMARY KEY, - currency_symbol VARCHAR(72) NOT NULL REFERENCES currency (symbol), - implementation_symbol VARCHAR(72) NOT NULL, - chain VARCHAR(72) NOT NULL REFERENCES chains (name), - token BOOLEAN NOT NULL, - token_address VARCHAR(72), - token_name VARCHAR(72), - withdraw_enabled BOOLEAN NOT NULL, - withdraw_fee DECIMAL NOT NULL, - withdraw_min DECIMAL NOT NULL, - decimal INTEGER NOT NULL, - UNIQUE (currency_symbol, chain, implementation_symbol) -); - -CREATE TABLE IF NOT EXISTS deposits -( - id SERIAL PRIMARY KEY, - hash VARCHAR(100) UNIQUE NOT NULL, - chain VARCHAR(72) NOT NULL REFERENCES chains (name), - token BOOLEAN NOT NULL, - token_address VARCHAR(72), - amount DECIMAL NOT NULL, - depositor VARCHAR(72) NOT NULL, - depositor_memo VARCHAR(72) -); - - - diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/pom.xml b/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/pom.xml index 3bc027eee..401349381 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/pom.xml +++ b/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/pom.xml @@ -1,6 +1,6 @@ - 4.0.0 diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/ExtractBackgroundAuth.kt b/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/ExtractBackgroundAuth.kt index 048392ca6..77308d779 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/ExtractBackgroundAuth.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/ExtractBackgroundAuth.kt @@ -4,13 +4,10 @@ package co.nilin.opex.bcgateway.ports.walletproxy.impl import co.nilin.opex.bcgateway.core.model.otc.LoginRequest import co.nilin.opex.bcgateway.core.spi.AuthProxy import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Profile import org.springframework.core.env.Environment - import org.springframework.stereotype.Component - @Component class ExtractBackgroundAuth(private val authProxy: AuthProxy, private val environment: Environment) { @@ -29,7 +26,6 @@ class ExtractBackgroundAuth(private val authProxy: AuthProxy, private val enviro } - //save for config Reactive Security context instead of using api diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/WalletProxyImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/WalletProxyImpl.kt index 1f824d9a5..7e46a51ab 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/WalletProxyImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-wallet-proxy/src/main/kotlin/co/nilin/opex/bcgateway/ports/walletproxy/impl/WalletProxyImpl.kt @@ -3,7 +3,6 @@ package co.nilin.opex.bcgateway.ports.walletproxy.impl import co.nilin.opex.bcgateway.core.spi.WalletProxy import co.nilin.opex.bcgateway.ports.walletproxy.model.TransferResult import kotlinx.coroutines.reactive.awaitFirst -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.core.ParameterizedTypeReference import org.springframework.stereotype.Component @@ -14,38 +13,36 @@ import java.net.URI inline fun typeRef(): ParameterizedTypeReference = object : ParameterizedTypeReference() {} @Component -class WalletProxyImpl(private val webClient: WebClient, - private val extractBackgroundAuth: ExtractBackgroundAuth) : WalletProxy { +class WalletProxyImpl( + private val webClient: WebClient, + private val extractBackgroundAuth: ExtractBackgroundAuth +) : WalletProxy { @Value("\${app.wallet.url}") private lateinit var baseUrl: String -// override suspend fun transfer(uuid: String, symbol: String, amount: BigDecimal, hash: String) { -// webClient.post() -// .uri(URI.create("$baseUrl/deposit/${amount}_${symbol}/${uuid}_main?transferRef=$hash")) -// .header("Content-Type", "application/json") -// .header("Authorization", "Bearer ${extractBackgroundAuth.extractToken()}") -// .retrieve() -// .onStatus({ t -> t.isError }, { it.createException() }) -// .bodyToMono(typeRef()) -// .awaitFirst() -// } - - override suspend fun transfer(uuid: String, symbol: String, amount: BigDecimal, hash: String, chain: String) { + override suspend fun transfer( + uuid: String, + symbol: String, + amount: BigDecimal, + hash: String, + chain: String, + gatewayUuid: String? + ) { val token = extractBackgroundAuth.extractToken() webClient.post() - .uri(URI.create("$baseUrl/deposit/${amount}_${chain}_${symbol}/${uuid}_MAIN?transferRef=$hash")) - .headers { httpHeaders -> - run { - httpHeaders.add("Content-Type", "application/json"); - token?.let { httpHeaders.add("Authorization", "Bearer $it") } - } + .uri(URI.create("$baseUrl/deposit/${amount}_${chain}_${symbol}/${uuid}_MAIN?transferRef=$hash&gatewayUuid=$gatewayUuid")) + .headers { httpHeaders -> + run { + httpHeaders.add("Content-Type", "application/json"); + token?.let { httpHeaders.add("Authorization", "Bearer $it") } } - .retrieve() - .onStatus({ t -> t.isError }, { it.createException() }) - .bodyToMono(typeRef()) - .awaitFirst() + } + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono(typeRef()) + .awaitFirst() } } diff --git a/bc-gateway/pom.xml b/bc-gateway/pom.xml index cc2ddb897..ee39af0a8 100644 --- a/bc-gateway/pom.xml +++ b/bc-gateway/pom.xml @@ -1,5 +1,5 @@ - core @@ -20,6 +20,7 @@ bc-gateway-ports/bc-gateway-persister-postgres bc-gateway-ports/bc-gateway-wallet-proxy bc-gateway-ports/bc-gateway-auth-proxy + bc-gateway-ports/bc-gateway-omniwallet-proxy bc-gateway-ports/bc-gateway-eventlistener-kafka @@ -56,6 +57,11 @@ bc-gateway-auth-proxy ${project.version} + + co.nilin.opex.bcgateway.ports.omniwallet + bc-gateway-omniwallet-proxy + ${project.version} + co.nilin.opex.bcgateway.ports.kafka.listener bc-gateway-eventlistener-kafka @@ -71,12 +77,6 @@ interceptors ${interceptor.version} - - co.nilin.opex.utility - preferences - ${preferences.version} - - org.springframework.batch spring-batch-core diff --git a/common/pom.xml b/common/pom.xml index ce854d4a3..3b306904f 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -1,6 +1,6 @@ - 4.0.0 @@ -14,6 +14,7 @@ jar common Common codes and logic + 1.0.1-beta.38 @@ -28,6 +29,22 @@ co.nilin.opex.utility error-handler + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.springframework.boot + spring-boot-starter-webflux + + + co.nilin.opex.utility + interceptors + @@ -37,7 +54,12 @@ error-handler ${error-hanlder.version} + + co.nilin.opex.utility + interceptors + ${interceptor.version} + - \ No newline at end of file + diff --git a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt index 5e3b1d774..6edb3ba9d 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt @@ -18,16 +18,25 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus InvalidRequestBody(1021, "Request body is invalid", HttpStatus.BAD_REQUEST), NoRecordFound(1022, "No record found for this service", HttpStatus.NOT_FOUND), ServiceDeprecated(1023, "Service deprecated", HttpStatus.SERVICE_UNAVAILABLE), + RateLimit(1024, null, HttpStatus.TOO_MANY_REQUESTS), + InvalidSignature(1025, null, HttpStatus.BAD_REQUEST), + InvalidTime(1026, null, HttpStatus.BAD_REQUEST), + // code 2000: accountant InvalidPair(2001, "%s is not available", HttpStatus.BAD_REQUEST), InvalidPairFee(2002, "%s fee is not available", HttpStatus.BAD_REQUEST), - PairFeeNotFound(2002, "No fee for requested pair found", HttpStatus.NOT_FOUND), - + PairFeeNotFound(2003, "No fee for requested pair found", HttpStatus.NOT_FOUND), + FeeConfigAlreadyExists(2004, "Fee config already exists", HttpStatus.CONFLICT), + FeeConfigNotFound(2005, "Fee config not found", HttpStatus.NOT_FOUND), + WithdrawAmountExceeds(2006, "The requested withdraw amount exceeds your daily limit", HttpStatus.BAD_REQUEST), + WithdrawLimitConfigNotFound(2007, "Withdraw limit config not found", HttpStatus.NOT_FOUND), // code 3000: matching-engine // code 4000: matching-gateway SubmitOrderForbiddenByAccountant(4001, null, HttpStatus.BAD_REQUEST), + InvalidOrderType(4002, "Invalid order type", HttpStatus.BAD_REQUEST), + InvalidQuantity(4003, "Invalid quantity", HttpStatus.BAD_REQUEST), // code 5000: user-management EmailAlreadyVerified(5001, "Email is already verified", HttpStatus.BAD_REQUEST), @@ -39,9 +48,19 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus AlreadyInKYC(5007, "KYC flow for this user has executed", HttpStatus.BAD_REQUEST), UserKYCBlocked(5008, "User is blocked from KYC", HttpStatus.BAD_REQUEST), InvalidPassword(5009, "Password is not valid", HttpStatus.BAD_REQUEST), - UserAlreadyExists(5009, "User with email is already registered", HttpStatus.BAD_REQUEST), + UserAlreadyExists(5009, "User is already registered", HttpStatus.BAD_REQUEST), LoginIsLimited(5010, "Your email is not in whitelist", HttpStatus.BAD_REQUEST), RegisterIsLimited(5011, "Your email is not in whitelist", HttpStatus.BAD_REQUEST), + GmailNotFoundInToken(5012, "Email not found in Google token", HttpStatus.NOT_FOUND), + UserIDNotFoundInToken(5013, "Google user ID (sub) not found in token", HttpStatus.NOT_FOUND), + InvalidUsername(5014, "Invalid username", HttpStatus.BAD_REQUEST), + InvalidUserCredentials(5015, "Invalid user credentials", HttpStatus.BAD_REQUEST), + InvalidRegisterToken(5016, "Invalid register token", HttpStatus.BAD_REQUEST), + ExpiredOTP(5017, "OTP is expired", HttpStatus.BAD_REQUEST), + InvalidToken(5018, "Invalid token", HttpStatus.BAD_REQUEST), + InternalIdGenerateFailed(5019, "Internal id generate failed", HttpStatus.INTERNAL_SERVER_ERROR), + CaptchaRequired(5020, "Captcha required", HttpStatus.BAD_REQUEST), + UsernameOrPasswordIsIncorrect(5021, "Username or password is incorrect", HttpStatus.BAD_REQUEST), // code 6000: wallet WalletOwnerNotFound(6001, null, HttpStatus.NOT_FOUND), @@ -65,12 +84,41 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus WithdrawNotAllowed(6019, null, HttpStatus.BAD_REQUEST), DepositLimitExceeded(6020, "Deposit limit exceeded", HttpStatus.BAD_REQUEST), InvalidAmount(6021, "Invalid amount", HttpStatus.BAD_REQUEST), - WithdrawAlreadyProcessed(6022, "This withdraw request processed before", HttpStatus.BAD_REQUEST), + WithdrawCannotBeAccepted(6022, "Withdraw cannot be accepted", HttpStatus.BAD_REQUEST), InvalidAppliedFee(6023, "Applied fee is bigger than accepted fee", HttpStatus.BAD_REQUEST), WithdrawAmountExceedsWalletBalance(6024, "Withdraw amount exceeds wallet balance", HttpStatus.BAD_REQUEST), WithdrawAmountLessThanMinimum(6025, "Withdraw amount is less than minimum", HttpStatus.BAD_REQUEST), WithdrawCannotBeCanceled(6026, "Withdraw cannot be canceled", HttpStatus.BAD_REQUEST), WithdrawCannotBeRejected(6027, "Withdraw cannot be rejected", HttpStatus.BAD_REQUEST), + WithdrawAmountGreaterThanMaximum(6028, "Withdraw amount is more than maximum", HttpStatus.BAD_REQUEST), + ImplNotFound(6029, null, HttpStatus.NOT_FOUND), + InvalidWithdrawStatus(6030, "Withdraw status is invalid", HttpStatus.NOT_FOUND), + GatewayNotFount(6031, null, HttpStatus.NOT_FOUND), + GatewayIsExist(6032, null, HttpStatus.NOT_FOUND), + InvalidDeposit(6033, "Invalid deposit", HttpStatus.BAD_REQUEST), + TerminalIsExist(6034, "This identifier is exist", HttpStatus.BAD_REQUEST), + TerminalNotFound(6035, "Object not found", HttpStatus.BAD_REQUEST), + VoucherNotFound(6036, "Voucher not found", HttpStatus.NOT_FOUND), + InvalidVoucher(6037, "Invalid Voucher", HttpStatus.BAD_REQUEST), + PairIsNotAvailable(6038, "Pair is not available", HttpStatus.BAD_REQUEST), + VoucherGroupNotFound(6039, "Voucher Group not found", HttpStatus.NOT_FOUND), + VoucherGroupIsInactive(6040, "Voucher Group is inactive", HttpStatus.BAD_REQUEST), + VoucherAlreadyUsed(6041, "Voucher has already been used", HttpStatus.BAD_REQUEST), + VoucherExpired(6042, "Voucher has expired", HttpStatus.BAD_REQUEST), + VoucherSaleDataNotFound(6043, "Voucher sale data not found", HttpStatus.NOT_FOUND), + VoucherNotForSale(6044, "Voucher not for sale", HttpStatus.BAD_REQUEST), + VoucherUsageLimitExceeded(6045, "Voucher usage limit exceeded", HttpStatus.BAD_REQUEST), + InvalidMaximumAmount(6046, "Invalid maximum amount", HttpStatus.BAD_REQUEST), + InvalidMinimumAmount(6047, "Invalid minimum amount", HttpStatus.BAD_REQUEST), + WithdrawCannotBeDone(6048, "Withdraw cannot be done", HttpStatus.BAD_REQUEST), + WithdrawCannotBeRequested(6049, "Withdraw cannot be requested", HttpStatus.BAD_REQUEST), + OTPCannotBeRequested(6050, "OTP cannot be requested", HttpStatus.BAD_REQUEST), + WithdrawRequestExpired(6051,"Withdraw request expired", HttpStatus.BAD_REQUEST), + ForbiddenSwapPair(6052, null, HttpStatus.BAD_REQUEST), + TerminalLocalizationNotFound(6053, "Terminal localization not found", HttpStatus.NOT_FOUND), + CurrencyLocalizationNotFound(6051, "Currency localization not found", HttpStatus.NOT_FOUND), + IdentifierNotFound(6052, "Identifier not found", HttpStatus.NOT_FOUND), + // code 7000: api OrderNotFound(7001, "No order found", HttpStatus.NOT_FOUND), @@ -95,6 +143,67 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus // code 11000: market + // code 12000: otp + OTPConfigNotFound(12001, "Config for otp type not found", HttpStatus.NOT_FOUND), + UnableToSendOTP(12002, "Unable to send OTP code to the receiver", HttpStatus.INTERNAL_SERVER_ERROR), + OTPAlreadyRequested(12003, "OTP code is already requested for the receiver and OTP type", HttpStatus.BAD_REQUEST), + TOTPNotFound(12004, "TOTP for the requested user not found", HttpStatus.NOT_FOUND), + InvalidTOTPCode(12005, "TOTP code is invalid", HttpStatus.BAD_REQUEST), + TOTPSetupIncomplete(12006, "TOTP setup is incomplete", HttpStatus.BAD_REQUEST), + TOTPAlreadyRegistered(12007, "User already registered for TOTP", HttpStatus.BAD_REQUEST), + OTPDisabled(12008, "OTP for this receiver type is disabled", HttpStatus.INTERNAL_SERVER_ERROR), + + + //code 12000 profile + UserIdAlreadyExists(13001, "User with this id or email is already registered", HttpStatus.BAD_REQUEST), + InvalidLinkedAccount(13002, "Irrelevant account", HttpStatus.BAD_REQUEST), + AccountNotFound(13003, " Account not found", HttpStatus.BAD_REQUEST), + DuplicateAccount(13004, " Duplicate account", HttpStatus.BAD_REQUEST), + InvalidIban(13005, " Invalid iban number", HttpStatus.BAD_REQUEST), + InvalidCard(13006, " Invalid card number", HttpStatus.BAD_REQUEST), + ShahkarVerificationFailed(13007, "Shahkar Verification Failed", HttpStatus.BAD_REQUEST), + ProfileApprovalRequestAlreadyExists(13008, "Request Already Exists", HttpStatus.BAD_REQUEST), + ProfileNotfound(13009, "Profile not found", HttpStatus.NOT_FOUND), + EmailAlreadyExists(13010, "Email already exists", HttpStatus.BAD_REQUEST), + MobileAlreadyExists(13011, "Mobile already exists", HttpStatus.BAD_REQUEST), + InvalidEmail(13012, "Invalid email", HttpStatus.BAD_REQUEST), + InvalidMobile(13013, "Invalid mobile", HttpStatus.BAD_REQUEST), + MobileAlreadySet(13014, "Mobile already set", HttpStatus.BAD_REQUEST), + EmailAlreadySet(13015, "Email already set", HttpStatus.BAD_REQUEST), + ProfileAlreadyCompleted(13016, "Profile already completed", HttpStatus.BAD_REQUEST), + FirstNameIsNotSimilarEnough(13017, "The first name is not similar enough.", HttpStatus.BAD_REQUEST), + LastNameIsNotSimilarEnough(13018, "The last name is not similar enough.", HttpStatus.BAD_REQUEST), + ShahkarInquiryError(13019, "Shahkar inquiry error", HttpStatus.SERVICE_UNAVAILABLE), + ComparativeInquiryError(13020, "Comparative inquiry error", HttpStatus.SERVICE_UNAVAILABLE), + ProfileApprovalRequestNotfound(13021, "Profile approval request not found", HttpStatus.NOT_FOUND), + InvalidProfileApprovalRequestStatus(13022, "Invalid profile approval request status", HttpStatus.BAD_REQUEST), + InvalidBirthDate(13023, "Invalid birthdate", HttpStatus.BAD_REQUEST), + InvalidNationalCode(13024, "Invalid national code", HttpStatus.BAD_REQUEST), + InvalidMobileNumber(13025, "Invalid mobile number", HttpStatus.BAD_REQUEST), + IdentityInfoNotFound(13026, "Identity info not found", HttpStatus.NOT_FOUND), + CardOwnershipMismatch(13027, "Card does not belong to the provided data", HttpStatus.BAD_REQUEST), + IbanOwnershipMismatch(13028, "Iban does not belong to the provided data", HttpStatus.BAD_REQUEST), + CardOwnershipInquiryError(13029, " Card ownership inquiry error", HttpStatus.SERVICE_UNAVAILABLE), + IbanOwnershipInquiryError(13030, " Iban ownership inquiry error", HttpStatus.SERVICE_UNAVAILABLE), + CardNotActive(13031, "Card not active", HttpStatus.BAD_REQUEST), + CardIsExpired(13032, "Card is expired", HttpStatus.BAD_REQUEST), + CardAccountNumberNotValid(13033, "Card account number not valid", HttpStatus.BAD_REQUEST), + CardRegisteredAsLost(13034, "Card registered as lost", HttpStatus.CONFLICT), + CardRegisteredAsStolen(13035, "Card registered as stolen", HttpStatus.CONFLICT), + CardSourceBankNotActive(13036, "Card source bank not active", HttpStatus.SERVICE_UNAVAILABLE), + CardBlackListed(13037, "Card blacklisted", HttpStatus.FORBIDDEN), + IbanNotFound(13038, "IBAN not found", HttpStatus.NOT_FOUND), + IbanOwnerNotFound(13039, "IBAN owner not found", HttpStatus.NOT_FOUND), + IbanIsRequired(13040, "IBAN is required", HttpStatus.BAD_REQUEST), + IbanInfoInquiryError(13041, "IBAN info inquiry failed", HttpStatus.INTERNAL_SERVER_ERROR), + CardRequired(13042, "Card is required", HttpStatus.BAD_REQUEST), + CardTypeNotSupported(13043, "Card type not supported", HttpStatus.BAD_REQUEST), + IbanBlackListed(13044, "IBAN is blacklisted", HttpStatus.FORBIDDEN), + CardIbanInfoInquiryError(13045, "Card-IBAN info inquiry failed", HttpStatus.INTERNAL_SERVER_ERROR), + BankAccountAlreadyExist(13046, "Bank account already exist", HttpStatus.BAD_REQUEST), + BankAccountNotFound(13047, "Bank account not found", HttpStatus.NOT_FOUND), + AddressBookNotFound(13048, "Address book not found", HttpStatus.NOT_FOUND), + InvalidProfileData(13049, "Invalid profile data", HttpStatus.BAD_REQUEST) ; override fun code() = this.code diff --git a/common/src/main/kotlin/co/nilin/opex/common/CommonErrorConfig.kt b/common/src/main/kotlin/co/nilin/opex/common/config/CommonErrorConfig.kt similarity index 87% rename from common/src/main/kotlin/co/nilin/opex/common/CommonErrorConfig.kt rename to common/src/main/kotlin/co/nilin/opex/common/config/CommonErrorConfig.kt index c792bc752..56b228270 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/CommonErrorConfig.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/config/CommonErrorConfig.kt @@ -1,21 +1,22 @@ -package co.nilin.opex.common - -import co.nilin.opex.utility.error.data.ErrorRep -import co.nilin.opex.utility.error.spi.ErrorConfig -import org.springframework.stereotype.Component - -@Component -class CommonErrorConfig : ErrorConfig { - - override fun findErrorByCode(code: Int?): ErrorRep? { - return OpexError.findByCode(code) - } - - override fun internalServerError(): ErrorRep { - return OpexError.InternalServerError - } - - override fun invalidRequestParamError(): ErrorRep { - return OpexError.InvalidRequestParam - } +package co.nilin.opex.common.config + +import co.nilin.opex.common.OpexError +import co.nilin.opex.utility.error.data.ErrorRep +import co.nilin.opex.utility.error.spi.ErrorConfig +import org.springframework.stereotype.Component + +@Component +class CommonErrorConfig : ErrorConfig { + + override fun findErrorByCode(code: Int?): ErrorRep? { + return OpexError.findByCode(code) + } + + override fun internalServerError(): ErrorRep { + return OpexError.InternalServerError + } + + override fun invalidRequestParamError(): ErrorRep { + return OpexError.InvalidRequestParam + } } \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/config/CommonWebclientConfig.kt b/common/src/main/kotlin/co/nilin/opex/common/config/CommonWebclientConfig.kt new file mode 100644 index 000000000..a7d3c6f93 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/config/CommonWebclientConfig.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.common.config + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class CommonWebclientConfig { + @Bean("CommonWebClient") + @ConditionalOnMissingBean + fun commonWebClient(): CommonWebClient { + return CommonWebClient(WebClient.builder() + .build()) + } +} +class CommonWebClient(val delegate: WebClient) diff --git a/common/src/main/kotlin/co/nilin/opex/common/config/ErrorConfig.kt b/common/src/main/kotlin/co/nilin/opex/common/config/ErrorConfig.kt new file mode 100644 index 000000000..c501cbd4b --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/config/ErrorConfig.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.common.config + +import co.nilin.opex.common.service.CustomErrorTranslator +import co.nilin.opex.utility.error.spi.ErrorTranslator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary + +@Configuration +class ErrorConfig { + @Bean + @Primary + fun translator(): ErrorTranslator { + return CustomErrorTranslator() + } + +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/config/MessageSourceConfig.kt b/common/src/main/kotlin/co/nilin/opex/common/config/MessageSourceConfig.kt new file mode 100644 index 000000000..49dfddc30 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/config/MessageSourceConfig.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.common.config + +import org.springframework.context.MessageSource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.support.ReloadableResourceBundleMessageSource +@Configuration +class MessageSourceConfig { + @Bean + fun messageSource(): MessageSource { + val messageSource = ReloadableResourceBundleMessageSource() + messageSource.setBasename("classpath:messages") + messageSource.setCacheSeconds(10) //reload messages every 10 seconds + messageSource.setDefaultEncoding("UTF-8") + return messageSource + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/CalenderType.kt b/common/src/main/kotlin/co/nilin/opex/common/data/CalenderType.kt new file mode 100644 index 000000000..e36d83c29 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/CalenderType.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.common.data + +enum class CalenderType { + JALALI, HIJRI, GREGORIAN +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/LanguageDirection.kt b/common/src/main/kotlin/co/nilin/opex/common/data/LanguageDirection.kt new file mode 100644 index 000000000..2263833c8 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/LanguageDirection.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.common.data + +enum class LanguageDirection { + RTL, LTR +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/LanguageOption.kt b/common/src/main/kotlin/co/nilin/opex/common/data/LanguageOption.kt new file mode 100644 index 000000000..9ba072ca0 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/LanguageOption.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.common.data + +data class LanguageOption( + val label: String, + val nativeLabel: String, + val direction: LanguageDirection, + val defaultCalender: CalenderType, +) \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/MessageCategory.kt b/common/src/main/kotlin/co/nilin/opex/common/data/MessageCategory.kt new file mode 100644 index 000000000..9b9c32669 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/MessageCategory.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.common.data +//After adjusting versions in various modules it should be moved to common project + +enum class MessageCategory { + CURRENCY, ERROR_CODE, CONTACT, KYC_PARAM, DEFAULT +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/MessageTranslation.kt b/common/src/main/kotlin/co/nilin/opex/common/data/MessageTranslation.kt new file mode 100644 index 000000000..a7bd0cf1d --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/MessageTranslation.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.common.data + +//After adjusting versions in various modules it should be moved to common project + +data class MessageTranslation( + val key: String, + var message: String? = "", + var language: UserLanguage, + var messageCategory: MessageCategory?=MessageCategory.DEFAULT + ) diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/Theme.kt b/common/src/main/kotlin/co/nilin/opex/common/data/Theme.kt new file mode 100644 index 000000000..a3b814cf3 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/Theme.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.common.data + +enum class Theme { + DARK, LIGHT, SYSTEM +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/UserLanguage.kt b/common/src/main/kotlin/co/nilin/opex/common/data/UserLanguage.kt new file mode 100644 index 000000000..8abf88463 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/UserLanguage.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.common.data + +import co.nilin.opex.common.service.GlobalWebConfigCache + +enum class UserLanguage { + EN, FA, AR, UZ; + + companion object { + + fun getDefaultLanguage(): String = + GlobalWebConfigCache.webConfig?.defaultLanguage?.name ?: EN.name + + fun safeValueOf(lang: String?): UserLanguage { + return try { + if (lang.isNullOrBlank()) valueOf(getDefaultLanguage()) + else valueOf(lang.uppercase()) + } catch (e: IllegalArgumentException) { + valueOf(getDefaultLanguage()) + } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/data/WebConfig.kt b/common/src/main/kotlin/co/nilin/opex/common/data/WebConfig.kt new file mode 100644 index 000000000..7a129f612 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/data/WebConfig.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.common.data + +data class WebConfig( + val logoUrl: String?, + val title: String?, + val description: String?, + var defaultLanguage: UserLanguage?, + var supportedLanguages: Map?, + var supportedCalenders: List?, + val defaultTheme: Theme?, + var supportedThemes: List?, + val supportEmail: String?, +) \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/proxy/CustomMessageClient.kt b/common/src/main/kotlin/co/nilin/opex/common/proxy/CustomMessageClient.kt new file mode 100644 index 000000000..178c1da8b --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/proxy/CustomMessageClient.kt @@ -0,0 +1,30 @@ +package co.nilin.opex.common.proxy + +import co.nilin.opex.common.config.CommonWebClient +import co.nilin.opex.common.data.MessageTranslation +import co.nilin.opex.common.utils.typeRef +import kotlinx.coroutines.reactive.awaitFirst +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component + + +@Component +@ConditionalOnProperty(name = ["app.custom-message.enabled"], havingValue = "true", matchIfMissing = false) +class CustomMessageClient( + @Qualifier("CommonWebClient") private val webClient: CommonWebClient +) { + @Value("\${app.custom-message.base-url}") + private lateinit var customMessageBaseUrl: String + suspend fun getMessagesUpdatedAfter(lastUpdate: Long?): List? { + return webClient.delegate.get().uri(customMessageBaseUrl) { + it.queryParam("last-update", lastUpdate) + it.build() + }.retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono(typeRef>()) + .log() + .awaitFirst() + } +} diff --git a/common/src/main/kotlin/co/nilin/opex/common/proxy/CustomUserLanguageClient.kt b/common/src/main/kotlin/co/nilin/opex/common/proxy/CustomUserLanguageClient.kt new file mode 100644 index 000000000..4aec686f2 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/proxy/CustomUserLanguageClient.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.common.proxy + +import co.nilin.opex.common.config.CommonWebClient +import co.nilin.opex.common.data.WebConfig +import co.nilin.opex.common.utils.typeRef +import kotlinx.coroutines.reactive.awaitFirst +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component + + +@Component +@ConditionalOnProperty(name = ["app.custom-user-language.enabled"], havingValue = "true", matchIfMissing = false) +class CustomUserLanguageClient( + @Qualifier("CommonWebClient") private val webClient: CommonWebClient +) { + + suspend fun getWebConfig(): WebConfig { + return webClient.delegate.get().uri("http://config:8080/web/v1") { + it.build() + }.retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono(typeRef()) + .log() + .awaitFirst() + } +} diff --git a/common/src/main/kotlin/co/nilin/opex/common/security/CustomJwtAuthConverter.kt b/common/src/main/kotlin/co/nilin/opex/common/security/CustomJwtAuthConverter.kt new file mode 100644 index 000000000..3817beaf0 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/security/CustomJwtAuthConverter.kt @@ -0,0 +1,37 @@ +package co.nilin.opex.common.security + +import org.springframework.core.convert.converter.Converter +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +import reactor.core.publisher.Mono + +class ReactiveCustomJwtConverter : Converter> { + + override fun convert(source: Jwt): Mono { + val permissions = source.getClaimAsStringList("permissions") + ?.map { SimpleGrantedAuthority("PERM_${it}") } + ?.toList() ?: emptyList() + val roles = source.getClaimAsStringList("roles") + ?.map { SimpleGrantedAuthority("ROLE_${it}") } + ?.toList() ?: emptyList() + return Mono.just(JwtAuthenticationToken(source, roles + permissions)) + } +} + +class CustomJwtConverter : Converter { + + override fun convert(source: Jwt): AbstractAuthenticationToken { + val permissions = source.getClaimAsStringList("permissions") + ?.map { SimpleGrantedAuthority("PERM_${it}") } + ?.toList() ?: emptyList() + val roles = source.getClaimAsStringList("roles") + ?.map { SimpleGrantedAuthority("ROLE_${it}") } + ?.toList() ?: emptyList() + return JwtAuthenticationToken(source, roles + permissions) + } +} diff --git a/common/src/main/kotlin/co/nilin/opex/common/security/JwtUtils.kt b/common/src/main/kotlin/co/nilin/opex/common/security/JwtUtils.kt new file mode 100644 index 000000000..c054b4b06 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/security/JwtUtils.kt @@ -0,0 +1,20 @@ +package co.nilin.opex.common.security + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.util.* + +object JwtUtils { + private val mapper = jacksonObjectMapper() + + fun decodePayload(token: String): Map { + val parts = token.split(".") + require(parts.size == 3) { "Invalid JWT token" } + val json = String(Base64.getUrlDecoder().decode(parts[1])) + return mapper.readValue(json, Map::class.java) as Map + } + + fun extractRoles(token: String): List { + val payload = decodePayload(token) + return (payload["roles"] as? List<*>)?.filterIsInstance() ?: emptyList() + } +} \ No newline at end of file diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/SecurityExtension.kt b/common/src/main/kotlin/co/nilin/opex/common/security/SecurityExtension.kt similarity index 71% rename from api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/SecurityExtension.kt rename to common/src/main/kotlin/co/nilin/opex/common/security/SecurityExtension.kt index 3cd8a43ca..9d7614c62 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/SecurityExtension.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/security/SecurityExtension.kt @@ -1,6 +1,7 @@ -package co.nilin.opex.api.ports.binance.util +package co.nilin.opex.common.security import org.springframework.security.core.context.SecurityContext +import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken fun SecurityContext.jwtAuthentication(): JwtAuthenticationToken { @@ -8,5 +9,5 @@ fun SecurityContext.jwtAuthentication(): JwtAuthenticationToken { } fun JwtAuthenticationToken.tokenValue(): String { - return this.token.tokenValue + return (this.principal as Jwt).tokenValue } \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/service/CustomErrorTranslator.kt b/common/src/main/kotlin/co/nilin/opex/common/service/CustomErrorTranslator.kt new file mode 100644 index 000000000..9e18f268d --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/service/CustomErrorTranslator.kt @@ -0,0 +1,38 @@ +package co.nilin.opex.common.service + +import co.nilin.opex.common.utils.LanguageUtils.getUserLanguage +import co.nilin.opex.utility.error.data.DefaultExceptionResponse +import co.nilin.opex.utility.error.data.OpexException +import co.nilin.opex.utility.error.spi.ErrorTranslator +import co.nilin.opex.utility.error.spi.ExceptionResponse +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono + +@Service +class CustomErrorTranslator() : ErrorTranslator { + @Autowired + private lateinit var translationCacheService: TranslationCacheService + + private val logger = LoggerFactory.getLogger(CustomErrorTranslator::class.java) + + override fun translate(ex: OpexException): Mono { + return getUserLanguage().map { + logger.info("going to translate error based on user language:{}", it) + + DefaultExceptionResponse( + ex.error.errorName(), + ex.error.code(), + translationCacheService.getMessage(ex.error.errorName().toString(), it), + ex.status ?: ex.error.status(), + ex.data, + ex.crimeScene + ) + } + } +} + + + + diff --git a/common/src/main/kotlin/co/nilin/opex/common/service/CustomUserLanguageConfig.kt b/common/src/main/kotlin/co/nilin/opex/common/service/CustomUserLanguageConfig.kt new file mode 100644 index 000000000..ffb148a60 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/service/CustomUserLanguageConfig.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.common.service + +import co.nilin.opex.common.data.UserLanguage.EN +import co.nilin.opex.utility.interceptors.spi.UserLanguageConfig +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Service + +@Service +@Primary +class CustomUserLanguageConfig : UserLanguageConfig { + override fun getDefaultLanguage(): String = + runCatching { + GlobalWebConfigCache.webConfig?.defaultLanguage?.name + }.getOrNull() ?: EN.name +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/service/TranslationCacheService.kt b/common/src/main/kotlin/co/nilin/opex/common/service/TranslationCacheService.kt new file mode 100644 index 000000000..b661a1d28 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/service/TranslationCacheService.kt @@ -0,0 +1,68 @@ +package co.nilin.opex.common.service + +import co.nilin.opex.common.data.MessageTranslation +import co.nilin.opex.common.data.UserLanguage +import co.nilin.opex.common.proxy.CustomMessageClient +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.stereotype.Service +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import javax.annotation.PostConstruct +import javax.annotation.PreDestroy + +@Service +class TranslationCacheService( + private val customMessageClient: CustomMessageClient?, + private val messageSource: MessageSource? +) { + + private val cache: MutableMap, MessageTranslation> = ConcurrentHashMap() + private var lastUpdate: Long? = null + private var job: Job? = null + private val logger = LoggerFactory.getLogger(TranslationCacheService::class.java) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @PostConstruct + fun start() { + job = scope.launch { + logger.info("Going to get messages which are updated after: {}", lastUpdate) + while (isActive) { + try { + if (customMessageClient != null) { + val newMessages = customMessageClient.getMessagesUpdatedAfter(lastUpdate) + newMessages?.forEach { msg -> + cache[Pair(msg.key, msg.language)] = + MessageTranslation(msg.key, msg.message, msg.language) + } + } + lastUpdate = System.currentTimeMillis() + delay(30_000) + } catch (e: Exception) { + logger.error("Error fetching messages", e) + } + } + } + } + + + fun getMessage(key: String, userLanguage: String): String? { + val cached = cache[Pair(key, UserLanguage.safeValueOf(userLanguage))] + if (cached != null) { + return cached.message + } + return try { + messageSource?.getMessage(key, null, Locale(userLanguage.lowercase())) + } catch (ex: Exception) { + key + } + } + + @PreDestroy + fun stop() { + job?.cancel() + } + + +} diff --git a/common/src/main/kotlin/co/nilin/opex/common/service/WebConfigCacheService.kt b/common/src/main/kotlin/co/nilin/opex/common/service/WebConfigCacheService.kt new file mode 100644 index 000000000..b27a9f1f8 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/service/WebConfigCacheService.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.common.service + +import co.nilin.opex.common.data.WebConfig +import co.nilin.opex.common.proxy.CustomUserLanguageClient +import kotlinx.coroutines.runBlocking +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service + +object GlobalWebConfigCache { + @Volatile + var webConfig: WebConfig? = null +} + +@ConditionalOnProperty(name = ["app.custom-user-language.enabled"], havingValue = "true", matchIfMissing = false) +@Service +class WebConfigService( + private val configClient: CustomUserLanguageClient, +) { + + @Scheduled(fixedDelay = 5 * 60 * 1000) + fun refreshWebConfig() = runBlocking { + try { + val webConfig: WebConfig = configClient.getWebConfig() + + GlobalWebConfigCache.webConfig = webConfig + + } catch (e: Exception) { + println("WebConfig refresh failed: ${e.message}") + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/utils/CacheManager.kt b/common/src/main/kotlin/co/nilin/opex/common/utils/CacheManager.kt new file mode 100644 index 000000000..7f32e25ce --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/utils/CacheManager.kt @@ -0,0 +1,43 @@ +package co.nilin.opex.common.utils + +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +@Component("appCacheManager") +class CacheManager { + + private val cacheMap = ConcurrentHashMap>() + + data class CacheEntry( + val value: T, + val timestamp: Long + ) + + fun put(key: K, value: V, expirationTime: Long, timeUnit: TimeUnit) { + val expirationMillis = timeUnit.toMillis(expirationTime) + cacheMap[key] = CacheEntry(value, System.currentTimeMillis() + expirationMillis) + } + + fun get(key: K): V? { + val entry = cacheMap[key] + return if (entry != null && !isExpired(entry)) { + entry.value + } else { + cacheMap.remove(key) + null + } + } + + private fun isExpired(entry: CacheEntry): Boolean { + return System.currentTimeMillis() > entry.timestamp + } + + fun remove(key: K) { + cacheMap.remove(key) + } + + fun clear() { + cacheMap.clear() + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/utils/Extensions.kt b/common/src/main/kotlin/co/nilin/opex/common/utils/Extensions.kt index 360f34156..b50607871 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/utils/Extensions.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/utils/Extensions.kt @@ -1,8 +1,11 @@ package co.nilin.opex.common.utils + + fun justTry(action: () -> Unit) { try { action() } catch (_: Exception) { } -} \ No newline at end of file +} + 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 new file mode 100644 index 000000000..fc2a0a0c5 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/utils/LanguageUtils.kt @@ -0,0 +1,19 @@ +package co.nilin.opex.common.utils + +import co.nilin.opex.common.data.UserLanguage +import co.nilin.opex.common.service.GlobalWebConfigCache +import reactor.core.publisher.Mono + +object LanguageUtils { + + fun getUserLanguage(): Mono = + Mono.deferContextual { ctx -> Mono.just(ctx.getOrDefault("lang", getDefaultUserLanguage())!!) } + + fun getDefaultUserLanguage(): String { + return try { + GlobalWebConfigCache.webConfig?.defaultLanguage?.toString() ?: UserLanguage.EN.toString() + } catch (e: Exception) { + UserLanguage.EN.toString() + } + } +} \ No newline at end of file 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/common/src/main/kotlin/co/nilin/opex/common/utils/ProxyUtils.kt b/common/src/main/kotlin/co/nilin/opex/common/utils/ProxyUtils.kt new file mode 100644 index 000000000..d8460685a --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/utils/ProxyUtils.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.common.utils + +import org.springframework.core.ParameterizedTypeReference + +inline fun typeRef(): ParameterizedTypeReference = object : ParameterizedTypeReference() {} diff --git a/common/src/main/kotlin/co/nilin/opex/common/utils/SignVerifier.kt b/common/src/main/kotlin/co/nilin/opex/common/utils/SignVerifier.kt new file mode 100644 index 000000000..288514a47 --- /dev/null +++ b/common/src/main/kotlin/co/nilin/opex/common/utils/SignVerifier.kt @@ -0,0 +1,58 @@ +package co.nilin.opex.common.utils + + +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 + +class SignVerifier{ + + fun verify(signatureAlgorithm: String , publicKeyPem: String, payload: String, signatureBase64: String) { + val signatureBytes = try { + Base64.getDecoder().decode(signatureBase64) + } catch (ex: Exception) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid fiat scanner signature encoding") + } + + val publicKey = try { + val normalizedPem = publicKeyPem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("\\s".toRegex(), "") + + val keyBytes = Base64.getDecoder().decode(normalizedPem) + val keySpec = X509EncodedKeySpec(keyBytes) + + KeyFactory + .getInstance(resolveKeyFactoryAlgorithm(signatureAlgorithm)) + .generatePublic(keySpec) + } catch (ex: Exception) { + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid fiat scanner public key") + } + + val verified = try { + Signature.getInstance(signatureAlgorithm).apply { + initVerify(publicKey) + update(payload.toByteArray(Charsets.UTF_8)) + }.verify(signatureBytes) + } catch (ex: Exception) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Fiat scanner signature verification failed") + } + + if (!verified) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid fiat scanner signature") + } + } + + private fun resolveKeyFactoryAlgorithm(algorithm: String): String { + return when { + algorithm.equals("Ed25519", ignoreCase = true) -> "Ed25519" + algorithm.contains("RSA", ignoreCase = true) -> "RSA" + algorithm.contains("ECDSA", ignoreCase = true) -> "EC" + else -> "RSA" + } + } +} \ No newline at end of file diff --git a/common/src/main/resources/messages_en.properties b/common/src/main/resources/messages_en.properties new file mode 100644 index 000000000..07011b3b0 --- /dev/null +++ b/common/src/main/resources/messages_en.properties @@ -0,0 +1,155 @@ +# Generic errors +Error=Generic error +InternalServerError=Internal server error +BadRequest=Bad request +UnAuthorized=Unauthorized +Forbidden=Forbidden +NotFound=Not found +ServiceUnavailable=Service unavailable + +# Parameter errors +InvalidRequestParam=Parameter '%s' is either missing or invalid +InvalidRequestBody=Request body is invalid +NoRecordFound=No record found for this service +ServiceDeprecated=Service is deprecated + +# Accountant errors +InvalidPair=%s is not available +InvalidPairFee=%s fee is not available +PairFeeNotFound=No fee for requested pair found + +# Matching-gateway errors +SubmitOrderForbiddenByAccountant=Submitting order is forbidden by accountant +InvalidOrderType=Invalid order type +InvalidQuantity=Invalid quantity + +# User-management errors +EmailAlreadyVerified=Email is already verified +GroupNotFound=Group not found +OTPAlreadyEnabled=2FA/OTP already configured +UserNotFound=User not found +InvalidOTP=Invalid OTP +OTPRequired=OTP required +AlreadyInKYC=KYC flow for this user has executed +UserKYCBlocked=User is blocked from KYC +InvalidPassword=Password is not valid +UserAlreadyExists=User is already registered +LoginIsLimited=Your email is not in whitelist +RegisterIsLimited=Your email is not in whitelist +GmailNotFoundInToken=Email not found in Google token +UserIDNotFoundInToken=Google user ID (sub) not found in token +InvalidUsername=Invalid username +InvalidUserCredentials=Invalid user credentials +InvalidRegisterToken=Invalid register token +ExpiredOTP=OTP is expired +InvalidToken=Invalid token +InternalIdGenerateFailed=Internal ID generation failed +CaptchaRequired=Captcha required + +# Wallet errors +WalletOwnerNotFound=Wallet owner not found +WalletNotFound=Wallet not found +CurrencyNotFound=Currency not found +InvalidCashOutUsage=Use withdraw services +WithdrawNotFound=No matching withdraw request +NOT_EXCHANGEABLE_CURRENCIES=These two currencies can't be exchanged +CurrencyIsExist=Currency already exists +PairIsExist=Pair already exists +ForbiddenPair=Forbidden pair +InvalidRate=Invalid rate +PairNotFound=Pair not found +SourceIsEqualDest=Source and destination currency are the same +AtLeastNeedOneTransitiveSymbol=At least one transitive symbol is needed +CurrencyIsDisable=Currency is disabled +CurrencyIsTransitiveAndDisablingIsImpossible=Disabling transitive currency is impossible +InvalidReserveNumber=Invalid reserve number +CurrentSystemAssetsAreNotEnough=Current system assets are not enough +NotEnoughBalance=Not enough balance +WithdrawNotAllowed=Withdraw is not allowed +DepositLimitExceeded=Deposit limit exceeded +InvalidAmount=Invalid amount +WithdrawAlreadyProcessed=This withdraw request was already processed +InvalidAppliedFee=Applied fee is bigger than accepted fee +WithdrawAmountExceedsWalletBalance=Withdraw amount exceeds wallet balance +WithdrawAmountLessThanMinimum=Withdraw amount is less than minimum +WithdrawCannotBeCanceled=Withdraw cannot be canceled +WithdrawCannotBeRejected=Withdraw cannot be rejected +WithdrawAmountGreaterThanMaximum=Withdraw amount is more than maximum +ImplNotFound=Implementation not found +InvalidWithdrawStatus=Withdraw status is invalid +GatewayNotFount=Gateway not found +GatewayIsExist=Gateway already exists +InvalidDeposit=Invalid deposit +TerminalIsExist=This identifier already exists +TerminalNotFound=Terminal not found +VoucherNotFound=Voucher not found +InvalidVoucher=Invalid voucher +PairIsNotAvailable=Pair is not available +VoucherGroupNotFound=Voucher group not found +VoucherGroupIsInactive=Voucher group is inactive +VoucherAlreadyUsed=Voucher has already been used +VoucherExpired=Voucher has expired +VoucherSaleDataNotFound=Voucher sale data not found +VoucherNotForSale=Voucher is not for sale +VoucherUsageLimitExceeded=Voucher usage limit exceeded +InvalidMaximumAmount=Invalid maximum amount +InvalidMinimumAmount=Invalid minimum amount + +# API errors +OrderNotFound=No order found +SymbolNotFound=No symbol found +InvalidLimitForOrderBook=Valid limits: [5, 10, 20, 50, 100, 500, 1000, 5000] +InvalidLimitForRecentTrades=Valid limits: 1 min - 1000 max +InvalidPriceChangeDuration=Valid durations: [24h, 7d, 1m] +CancelOrderNotAllowed=Canceling this order is not allowed +InvalidInterval=Invalid interval +APIKeyLimitReached=Reached API key limit. Maximum number of API keys is 10 + +# Blockchain-gateway errors +ReservedAddressNotAvailable=No reserved address available +DuplicateToken=Asset already exists +ChainNotFound=Chain not found +CurrencyNotFoundBC=Currency not found +TokenNotFound=Coin/Token not found +InvalidAddressType=Address type is invalid + +# Captcha errors +InvalidCaptcha=Captcha is not valid + +# OTP errors +OTPConfigNotFound=Config for OTP type not found +UnableToSendOTP=Unable to send OTP code to the receiver +OTPAlreadyRequested=OTP code is already requested +TOTPNotFound=TOTP for the requested user not found +InvalidTOTPCode=TOTP code is invalid +TOTPSetupIncomplete=TOTP setup is incomplete +TOTPAlreadyRegistered=User already registered for TOTP +OTPDisabled=OTP for this receiver type is disabled + +# Profile errors +UserIdAlreadyExists=User with this ID or email is already registered +InvalidLinkedAccount=Irrelevant account +AccountNotFound=Account not found +DuplicateAccount=Duplicate account +InvalidIban=Invalid IBAN number +InvalidCard=Invalid card number +ShahkarVerificationFailed=Shahkar verification failed +ProfileApprovalRequestAlreadyExists=Profile approval request already exists +ProfileNotfound=Profile not found +EmailAlreadyExists=Email already exists +MobileAlreadyExists=Mobile already exists +InvalidEmail=Invalid email +InvalidMobile=Invalid mobile +MobileAlreadySet=Mobile already set +EmailAlreadySet=Email already set +ProfileAlreadyCompleted=Profile already completed +FirstNameIsNotSimilarEnough=The first name is not similar enough +LastNameIsNotSimilarEnough=The last name is not similar enough +ShahkarInquiryError=Shahkar inquiry error +ComparativeInquiryError=Comparative inquiry error +ProfileApprovalRequestNotfound=Profile approval request not found +InvalidProfileApprovalRequestStatus=Invalid profile approval request status +InvalidBirthDate=Invalid birthdate +InvalidNationalCode=Invalid national code +InvalidMobileNumber=Invalid mobile number +IdentityInfoNotFound=Identity info not found \ No newline at end of file diff --git a/common/src/main/resources/messages_fa.properties b/common/src/main/resources/messages_fa.properties new file mode 100644 index 000000000..928ba70f6 --- /dev/null +++ b/common/src/main/resources/messages_fa.properties @@ -0,0 +1,155 @@ +# Generic errors +Error=خطای عمومی +InternalServerError=خطای سرور داخلی +BadRequest=درخواست نامعتبر +UnAuthorized=دسترسی غیرمجاز +Forbidden=دسترسی ممنوع +NotFound=یافت نشد +ServiceUnavailable=سرویس در دسترس نیست + +# Parameter errors +InvalidRequestParam=پارامتر '%s' یا وجود ندارد یا نامعتبر است +InvalidRequestBody=بدنه درخواست نامعتبر است +NoRecordFound=رکوردی برای این سرویس یافت نشد +ServiceDeprecated=این سرویس منسوخ شده است + +# Accountant errors +InvalidPair=%s در دسترس نیست +InvalidPairFee=کارمزد %s در دسترس نیست +PairFeeNotFound=کارمزد برای جفت درخواست شده یافت نشد + +# Matching-gateway errors +SubmitOrderForbiddenByAccountant=ثبت سفارش توسط حسابدار ممنوع است +InvalidOrderType=نوع سفارش نامعتبر است +InvalidQuantity=تعداد نامعتبر است + +# User-management errors +EmailAlreadyVerified=ایمیل قبلاً تأیید شده است +GroupNotFound=گروه یافت نشد +OTPAlreadyEnabled=تأیید دو مرحله‌ای (OTP) قبلاً پیکربندی شده است +UserNotFound=کاربر یافت نشد +InvalidOTP=کد تأیید نامعتبر است +OTPRequired=کد تأیید لازم است +AlreadyInKYC=فرایند احراز هویت برای این کاربر قبلاً انجام شده است +UserKYCBlocked=کاربر از احراز هویت مسدود شده است +InvalidPassword=رمز عبور نامعتبر است +UserAlreadyExists=کاربر قبلاً ثبت‌نام کرده است +LoginIsLimited=ایمیل شما در لیست سفید نیست +RegisterIsLimited=ایمیل شما در لیست سفید نیست +GmailNotFoundInToken=ایمیل در توکن گوگل یافت نشد +UserIDNotFoundInToken=شناسه کاربری گوگل (sub) در توکن یافت نشد +InvalidUsername=نام کاربری نامعتبر است +InvalidUserCredentials=اطلاعات کاربری نامعتبر است +InvalidRegisterToken=توکن ثبت‌نام نامعتبر است +ExpiredOTP=کد تأیید منقضی شده است +InvalidToken=توکن نامعتبر است +InternalIdGenerateFailed=تولید شناسه داخلی با شکست مواجه شد +CaptchaRequired=کد امنیتی لازم است + +# Wallet errors +WalletOwnerNotFound=مالک کیف پول یافت نشد +WalletNotFound=کیف پول یافت نشد +CurrencyNotFound=ارز یافت نشد +InvalidCashOutUsage=از خدمات برداشت استفاده کنید +WithdrawNotFound=درخواست برداشت یافت نشد +NOT_EXCHANGEABLE_CURRENCIES=این دو ارز قابل تبادل نیستند +CurrencyIsExist=ارز از قبل وجود دارد +PairIsExist=جفت از قبل وجود دارد +ForbiddenPair=جفت ممنوع است +InvalidRate=نرخ نامعتبر است +PairNotFound=جفت یافت نشد +SourceIsEqualDest=ارز مبدا و مقصد یکسان هستند +AtLeastNeedOneTransitiveSymbol=حداقل به یک نماد انتقالی نیاز است +CurrencyIsDisable=ارز غیرفعال است +CurrencyIsTransitiveAndDisablingIsImpossible=غیرفعال کردن ارز انتقالی امکان‌پذیر نیست +InvalidReserveNumber=عدد رزرو نامعتبر است +CurrentSystemAssetsAreNotEnough=دارایی‌های فعلی سیستم کافی نیست +NotEnoughBalance=موجودی کافی نیست +WithdrawNotAllowed=برداشت مجاز نیست +DepositLimitExceeded=حد سپرده فراتر رفته است +InvalidAmount=مبلغ نامعتبر است +WithdrawAlreadyProcessed=این درخواست برداشت قبلاً پردازش شده است +InvalidAppliedFee=کارمزد اعمال‌شده بیشتر از حد مجاز است +WithdrawAmountExceedsWalletBalance=مبلغ برداشت بیشتر از موجودی کیف پول است +WithdrawAmountLessThanMinimum=مبلغ برداشت کمتر از حداقل مجاز است +WithdrawCannotBeCanceled=برداشت قابل لغو نیست +WithdrawCannotBeRejected=برداشت قابل رد نیست +WithdrawAmountGreaterThanMaximum=مبلغ برداشت بیشتر از حداکثر مجاز است +ImplNotFound=پیاده‌سازی یافت نشد +InvalidWithdrawStatus=وضعیت برداشت نامعتبر است +GatewayNotFount=درگاه یافت نشد +GatewayIsExist=درگاه از قبل وجود دارد +InvalidDeposit=سپرده نامعتبر است +TerminalIsExist=این شناسه از قبل وجود دارد +TerminalNotFound=ترمینال یافت نشد +VoucherNotFound=کارت هدیه یافت نشد +InvalidVoucher=کارت هدیه نامعتبر است +PairIsNotAvailable=جفت در دسترس نیست +VoucherGroupNotFound=گروه کارت هدیه یافت نشد +VoucherGroupIsInactive=گروه کارت هدیه غیرفعال است +VoucherAlreadyUsed=کارت هدیه قبلاً استفاده شده است +VoucherExpired=کارت هدیه منقضی شده است +VoucherSaleDataNotFound=اطلاعات فروش کارت هدیه یافت نشد +VoucherNotForSale=کارت هدیه برای فروش نیست +VoucherUsageLimitExceeded=محدودیت استفاده از کارت هدیه فراتر رفته است +InvalidMaximumAmount=حداکثر مبلغ نامعتبر است +InvalidMinimumAmount=حداقل مبلغ نامعتبر است + +# API errors +OrderNotFound=سفارش یافت نشد +SymbolNotFound=نماد یافت نشد +InvalidLimitForOrderBook=محدوده‌های معتبر: [5, 10, 20, 50, 100, 500, 1000, 5000] +InvalidLimitForRecentTrades=محدوده‌های معتبر: 1 دقیقه - 1000 حداکثر +InvalidPriceChangeDuration=مدت‌های معتبر: [24 ساعت، 7 روز، 1 ماه] +CancelOrderNotAllowed=لغو این سفارش مجاز نیست +InvalidInterval=بازه زمانی نامعتبر است +APIKeyLimitReached=به محدودیت کلید API رسیده‌اید. حداکثر تعداد کلید 10 است + +# Blockchain-gateway errors +ReservedAddressNotAvailable=آدرس رزرو شده در دسترس نیست +DuplicateToken=دارایی از قبل وجود دارد +ChainNotFound=زنجیره یافت نشد +CurrencyNotFoundBC=ارز یافت نشد +TokenNotFound=سکه/توکن یافت نشد +InvalidAddressType=نوع آدرس نامعتبر است + +# Captcha errors +InvalidCaptcha=کد امنیتی نامعتبر است + +# OTP errors +OTPConfigNotFound=تنظیمات نوع OTP یافت نشد +UnableToSendOTP=ارسال کد OTP به گیرنده ممکن نیست +OTPAlreadyRequested=کد OTP برای این گیرنده و نوع قبلاً درخواست شده است +TOTPNotFound=TOTP برای کاربر درخواست‌شده یافت نشد +InvalidTOTPCode=کد TOTP نامعتبر است +TOTPSetupIncomplete=راه‌اندازی TOTP کامل نشده است +TOTPAlreadyRegistered=کاربر قبلاً برای TOTP ثبت شده است +OTPDisabled=OTP برای این نوع گیرنده غیرفعال است + +# Profile errors +UserIdAlreadyExists=کاربری با این شناسه یا ایمیل از قبل ثبت شده است +InvalidLinkedAccount=حساب نامرتبط +AccountNotFound=حساب یافت نشد +DuplicateAccount=حساب تکراری +InvalidIban=شماره شبا نامعتبر است +InvalidCard=شماره کارت نامعتبر است +ShahkarVerificationFailed=تأیید شاهکار ناموفق بود +ProfileApprovalRequestAlreadyExists=درخواست تأیید پروفایل از قبل وجود دارد +ProfileNotfound=پروفایل یافت نشد +EmailAlreadyExists=ایمیل از قبل وجود دارد +MobileAlreadyExists=شماره موبایل از قبل وجود دارد +InvalidEmail=ایمیل نامعتبر است +InvalidMobile=موبایل نامعتبر است +MobileAlreadySet=موبایل از قبل تنظیم شده است +EmailAlreadySet=ایمیل از قبل تنظیم شده است +ProfileAlreadyCompleted=پروفایل قبلاً تکمیل شده است +FirstNameIsNotSimilarEnough=نام کوچک به اندازه کافی مشابه نیست +LastNameIsNotSimilarEnough=نام خانوادگی به اندازه کافی مشابه نیست +ShahkarInquiryError=خطای استعلام شاهکار +ComparativeInquiryError=خطای استعلام تطبیقی +ProfileApprovalRequestNotfound=درخواست تأیید پروفایل یافت نشد +InvalidProfileApprovalRequestStatus=وضعیت درخواست تأیید پروفایل نامعتبر است +InvalidBirthDate=تاریخ تولد نامعتبر است +InvalidNationalCode=کد ملی نامعتبر است +InvalidMobileNumber=شماره موبایل نامعتبر است +IdentityInfoNotFound=اطلاعات هویتی یافت نشد \ No newline at end of file diff --git a/device-management/device-management-app/Dockerfile b/device-management/device-management-app/Dockerfile new file mode 100644 index 000000000..c62bad8ee --- /dev/null +++ b/device-management/device-management-app/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jdk +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] +HEALTHCHECK --interval=45s --start-period=30s --retries=5 CMD curl -sf 'http://localhost:8080/actuator/health' >/dev/null || exit 1 diff --git a/device-management/device-management-app/pom.xml b/device-management/device-management-app/pom.xml new file mode 100644 index 000000000..ff9b70048 --- /dev/null +++ b/device-management/device-management-app/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + co.nilin.opex.device-management + device-management + 1.0.1-beta.7 + + + co.nilin.opex.device-management + device-management-app + device-management-app + Device management for core of opex + + + 11.0.10.Final + + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.springframework.boot + spring-boot-starter-webflux + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + io.projectreactor + reactor-test + test + + + org.springframework.cloud + spring-cloud-starter-consul-all + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.bouncycastle + bcprov-jdk15on + 1.60 + + + org.zalando + logbook-spring-boot-webflux-autoconfigure + 3.9.0 + + + co.nilin.opex.utility + error-handler + + + co.nilin.opex.utility + interceptors + + + com.zaxxer + HikariCP + + + org.springframework.cloud + spring-cloud-starter-vault-config + + + org.jsoup + jsoup + 1.15.3 + + + io.micrometer + micrometer-registry-prometheus + runtime + + + co.nilin.opex.device-management.ports + device-management-kafka-listener + + + co.nilin.opex.device-management + device-management-core + + + co.nilin.opex.device-management.ports + device-management-postgres + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/DeviceManagementApp.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/DeviceManagementApp.kt new file mode 100644 index 000000000..d7c92e460 --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/DeviceManagementApp.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.device.app + +import co.nilin.opex.utility.error.EnableOpexErrorHandler +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication(exclude = [LiquibaseAutoConfiguration::class]) +@ComponentScan("co.nilin.opex") +@EnableOpexErrorHandler +@EnableConfigurationProperties +@EnableScheduling +@EntityScan(basePackages = ["co.nilin.opex.device.ports.postgres.model"]) +class DeviceManagementApp + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/config/AppConfig.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/config/AppConfig.kt new file mode 100644 index 000000000..b63376583 --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/config/AppConfig.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.device.app.config + +import co.nilin.opex.device.core.spi.LoginEventListener +import co.nilin.opex.device.core.spi.LogoutEventListener +import co.nilin.opex.device.ports.kafka.consumer.LoginKafkaListener +import co.nilin.opex.device.ports.kafka.consumer.LogoutKafkaListener +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Configuration + + +@Configuration +class AppConfig { + @Autowired + fun configureEventListeners( + loginKafkaListener: LoginKafkaListener, + loginEventListener: LoginEventListener, + logoutKafkaListener: LogoutKafkaListener, + logoutEventListener: LogoutEventListener, + ) { + loginKafkaListener.addEventListener(loginEventListener) + logoutKafkaListener.addEventListener(logoutEventListener) + + } +} \ No newline at end of file diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/config/SecurityConfig.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/config/SecurityConfig.kt new file mode 100644 index 000000000..5469a71e8 --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/config/SecurityConfig.kt @@ -0,0 +1,38 @@ +package co.nilin.opex.device.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.config.Customizer +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.web.reactive.function.client.WebClient + +@EnableWebFluxSecurity +@Configuration +class SecurityConfig { + @Value("\${app.auth.cert-url}") + private lateinit var jwkUrl: String + + @Bean + fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http.csrf { it.disable() } + .authorizeExchange { + it.pathMatchers("/actuator/**").permitAll() + .anyExchange().permitAll() + } + .oauth2ResourceServer { it.jwt(Customizer.withDefaults()) } + .build() + } + + @Bean + @Throws(Exception::class) + fun reactiveJwtDecoder(): ReactiveJwtDecoder? { + return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + .webClient(WebClient.create()) + .build() + } +} \ No newline at end of file diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/controller/DeviceController.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/controller/DeviceController.kt new file mode 100644 index 000000000..b62a8dfe3 --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/controller/DeviceController.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.device.app.controller + +import co.nilin.opex.common.OpexError +import co.nilin.opex.device.core.data.SessionsRequest +import co.nilin.opex.device.core.data.UserSessionDevice +import co.nilin.opex.device.core.service.DeviceService +import co.nilin.opex.device.core.service.UserSessionDeviceService +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/devices") +class DeviceController( + private val deviceService: DeviceService, + private val userSessionDeviceService: UserSessionDeviceService) { + @PostMapping + suspend fun getLastSessions( + @RequestBody sessionsRequest: SessionsRequest, + ): ResponseEntity?> { + val sessions = userSessionDeviceService.getUserSessionsWithDevices(sessionsRequest) + return ResponseEntity.ok(sessions) + } + +// @GetMapping("/user/{userId}/all-devices") +// suspend fun getAllDevices(@PathVariable userId: String): ResponseEntity> { +// val userDevices = deviceService.fetchUserDevices(userId) +// val devices = deviceService.fetchDevicesByIds(userDevices.map { it.deviceId }) +// return ResponseEntity.ok(devices) +// } + +} diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/listener/LoginListener.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/listener/LoginListener.kt new file mode 100644 index 000000000..278f9dc2f --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/listener/LoginListener.kt @@ -0,0 +1,46 @@ +package co.nilin.opex.device.app.listener + + +import co.nilin.opex.device.core.service.UserSessionDeviceService +import co.nilin.opex.device.core.data.LoginEvent +import co.nilin.opex.device.core.spi.LoginEventListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + + +@Component +class LoginListener(private val userSessionDeviceService: UserSessionDeviceService) : LoginEventListener { + + + private val logger = LoggerFactory.getLogger(LoginListener::class.java) + + + override fun id(): String { + return "LoginListener" + } + + override fun onEvent( + event: LoginEvent, + partition: Int, offset: Long, timestamp: Long, eventId: String + ) { + logger.info("==========================================================================") + logger.info("Incoming login event: $event") + logger.info("==========================================================================") + with(event) { + CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + try { + userSessionDeviceService.upsertDevice(event) + logger.info("Session created, Device upserted successfully ") + } catch (ex: Exception) { + logger.error("Failed to create the session/ upsert the device", ex) + } + } } + + } + + +} \ No newline at end of file diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/listener/LogoutListener.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/listener/LogoutListener.kt new file mode 100644 index 000000000..293323e86 --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/listener/LogoutListener.kt @@ -0,0 +1,47 @@ +package co.nilin.opex.device.app.listener + + +import co.nilin.opex.device.core.data.LogoutEvent +import co.nilin.opex.device.core.service.SessionService +import co.nilin.opex.device.core.spi.LogoutEventListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + + +@Component +class LogoutListener(private val sessionService: SessionService) : LogoutEventListener { + + + private val logger = LoggerFactory.getLogger(LogoutListener::class.java) + + + override fun id(): String { + return "LogoutListener" + } + + override fun onEvent( + event: LogoutEvent, + partition: Int, offset: Long, timestamp: Long, eventId: String + ) { + logger.info("==========================================================================") + logger.info("Incoming logout event: $event") + logger.info("==========================================================================") + with(event) { + CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + try { + sessionService.logoutSession(event) + logger.info("Session terminated successfully ") + } catch (ex: Exception) { + logger.error("Failed to terminate the session", ex) + } + } + } + + } + + +} \ No newline at end of file diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/service/SessionExpirationScheduler.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/service/SessionExpirationScheduler.kt new file mode 100644 index 000000000..3b4b6e8db --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/service/SessionExpirationScheduler.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.device.app.service + +import co.nilin.opex.device.core.spi.SessionPersister +import jakarta.annotation.PostConstruct +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Duration + +@Component +class SessionExpirationScheduler( + private val sessionPersister: SessionPersister +) { + private val logger = LoggerFactory.getLogger(SessionExpirationScheduler::class.java) + + @PostConstruct + fun startScheduler() { + Flux.interval(Duration.ofMinutes(1)) + .flatMap { + Mono.fromCallable { + runBlocking { + sessionPersister.markExpiredSessions() + } + } + } + .subscribe( + { updated -> logger.info("Expired session update completed: $updated items") }, + { err -> logger.error("Failed to update expired sessions", err) } + ) + } +} diff --git a/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/utils/VaultUserIdMechanism.kt b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/utils/VaultUserIdMechanism.kt new file mode 100644 index 000000000..ababa6eab --- /dev/null +++ b/device-management/device-management-app/src/main/kotlin/co/nilin/opex/device/app/utils/VaultUserIdMechanism.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.device.app.utils + +import org.springframework.vault.authentication.AppIdUserIdMechanism + +class VaultUserIdMechanism() : AppIdUserIdMechanism { + override fun createUserId(): String { + return System.getenv("BACKEND_USER") + } +} \ No newline at end of file diff --git a/device-management/device-management-app/src/main/resources/application.yml b/device-management/device-management-app/src/main/resources/application.yml new file mode 100644 index 000000000..922caeb87 --- /dev/null +++ b/device-management/device-management-app/src/main/resources/application.yml @@ -0,0 +1,116 @@ +server: + port: 8080 +spring: + application: + name: opex-device-management + kafka: + bootstrap-servers: ${KAFKA_IP_PORT:localhost:9092} + consumer: + group-id: device-management + r2dbc: + url: r2dbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} + initialization-mode: always + pool: + initial-size: 5 + max-size: 32 + max-idle-time: 10m + max-life-time: 30m + max-acquire-time: 10s + max-create-connection-time: 10s + validation-query: SELECT 1 + datasource: + url: jdbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} + cloud: + bootstrap: + enabled: true + vault: + host: ${VAULT_HOST} + port: 8200 + scheme: http + authentication: APPID + app-id: + user-id: co.nilin.opex.device.app.utils.VaultUserIdMechanism + fail-fast: true + kv: + enabled: true + backend: secret + profile-separator: '/' + application-name: ${spring.application.name} + consul: + host: ${CONSUL_HOST:localhost} + port: 8500 + discovery: + #healthCheckPath: ${management.context-path}/health + instance-id: ${spring.application.name}:${server.port} + healthCheckInterval: 20s + prefer-ip-address: true + config: + import: vault://secret/${spring.application.name} + codec: + max-in-memory-size: 20MB +management: + endpoints: + web: + base-path: /actuator + exposure: + include: [ "health", "prometheus", "metrics" ] + endpoint: + health: + show-details: when_authorized + metrics: + enabled: true + prometheus: + enabled: true +logbook: + secure-filter: + enabled: true + format: + style: http + filter: + enabled: true + form-request-mode: BODY + attribute-extractors: + - type: JwtFirstMatchingClaimExtractor + claim-names: [ "sub", "subject" ] + obfuscate: + headers: + - Authorization + parameters: + - password + json-body-fields: + - password + replacement: "***" + write: + max-body-size: 10_000 #kb + predicate: + exclude: + - path: /auth** + - path: /actuator/** + - path: /swagger** + include: + - path: /test** + - path: /admin/** + methods: + - POST + - PUT +logging: + level: + org.apache.kafka: ERROR + co.nilin: INFO + org.zalando.logbook: TRACE + +app: + auth: + url: lb://opex-auth + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + client-id: none + client-secret: none + custom-message: + 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 diff --git a/device-management/device-management-core/.gitignore b/device-management/device-management-core/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/device-management/device-management-core/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/device-management/device-management-core/pom.xml b/device-management/device-management-core/pom.xml new file mode 100644 index 000000000..a4720c778 --- /dev/null +++ b/device-management/device-management-core/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + co.nilin.opex.device-management + device-management + 1.0.1-beta.7 + + + device-management-core + device-management-core + device-management-core + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.springframework.boot + spring-boot-starter-webflux + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + + + diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Device.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Device.kt new file mode 100644 index 000000000..cf990ba48 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Device.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.device.core.data + +import java.time.LocalDateTime + + +data class Device( + val id: Long? = null, + val deviceUuid: String, + val os: Os? = null, + val osVersion: String? = null, + val appVersion: String? = null, + val pushToken: String? = null, + val createDate: LocalDateTime?= LocalDateTime.now(), + val lastUpdateDate: LocalDateTime?= LocalDateTime.now() + +) diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/LoginEvent.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/LoginEvent.kt new file mode 100644 index 000000000..3eb017fc5 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/LoginEvent.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.device.core.data + +import java.time.LocalDateTime + + +data class LoginEvent( + val uuid: String, + val deviceUuid: String?, + val appVersion: String?, + val osVersion: String?, + val pushToken: String?, + val os: Os?, + val sessionId: String, + val expireDate: LocalDateTime, + ) : SessionEvent() + diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/LogoutEvent.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/LogoutEvent.kt new file mode 100644 index 000000000..d194aed4e --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/LogoutEvent.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.device.core.data + + +data class LogoutEvent( + val uuid: String, + val logoutOthers: Boolean? = false, + val sessionId: String, +) : SessionEvent() \ No newline at end of file diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Os.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Os.kt new file mode 100644 index 000000000..f5c5a6e2a --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Os.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.device.core.data + +enum class Os { + ANDROID, IOS, MOBILE_WEB, DESKTOP_WEB +} diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Session.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Session.kt new file mode 100644 index 000000000..25e8ac9d3 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/Session.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.device.core.data + +import java.time.LocalDateTime + +data class Session( + val sessionState: String, + val userId: String, + val deviceId: Long, + val status: SessionStatus = SessionStatus.ACTIVE, + val createDate: LocalDateTime? = LocalDateTime.now(), + val expireDate: LocalDateTime? +) diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionEvent.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionEvent.kt new file mode 100644 index 000000000..d8a10022f --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionEvent.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.device.core.data + +import java.time.LocalDateTime + +open class SessionEvent { + val time: LocalDateTime = LocalDateTime.now() +} + diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionStatus.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionStatus.kt new file mode 100644 index 000000000..836ef2ad8 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionStatus.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.device.core.data + +enum class SessionStatus { + ACTIVE, + EXPIRED, + TERMINATED +} \ No newline at end of file diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionsRequest.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionsRequest.kt new file mode 100644 index 000000000..5aae5aad8 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/SessionsRequest.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.device.core.data + +data class SessionsRequest( + val uuid: String, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, + val os: Os?=null, + val status: SessionStatus? = null +) \ No newline at end of file diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/UserDevice.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/UserDevice.kt new file mode 100644 index 000000000..d5dee93b3 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/UserDevice.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.device.core.data + +import java.time.LocalDateTime + + +data class UserDevice( + val userId: String, + val deviceId: Long, + val firstLoginDate: LocalDateTime? = null, + val lastLoginDate: LocalDateTime? = null +) diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/UserSessionDevice.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/UserSessionDevice.kt new file mode 100644 index 000000000..a4403130c --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/data/UserSessionDevice.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.device.core.data + +import co.nilin.opex.device.core.data.SessionStatus +import java.time.LocalDateTime + +data class UserSessionDevice( + val deviceUuid: String?, + val os: Os?, + val osVersion: String?, + val appVersion: String?, + val firstLoginDate: LocalDateTime?, + val lastLoginDate: LocalDateTime?, + val sessionState: String?, + val sessionStatus: SessionStatus?, + val sessionCreateDate: LocalDateTime?, + val sessionExpireDate: LocalDateTime? +) \ No newline at end of file diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/DeviceService.java b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/DeviceService.java new file mode 100644 index 000000000..5d461b7ab --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/DeviceService.java @@ -0,0 +1,7 @@ +package co.nilin.opex.device.core.service; + +import org.springframework.stereotype.Service; + +@Service +public class DeviceService { +} diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/SessionService.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/SessionService.kt new file mode 100644 index 000000000..70750793d --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/SessionService.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.device.core.service + +import co.nilin.opex.device.core.data.LogoutEvent +import co.nilin.opex.device.core.spi.DevicePersister +import co.nilin.opex.device.core.spi.SessionPersister +import co.nilin.opex.device.core.spi.UserDevicePersister +import org.springframework.stereotype.Service + +@Service +class SessionService( + private val devicePersister: DevicePersister, + private val userDevicePersister: UserDevicePersister, + private val sessionPersister: SessionPersister +) { + suspend fun logoutSession(logoutEvent: LogoutEvent) { + if (logoutEvent.logoutOthers == false) { + sessionPersister.logoutSessionByState(logoutEvent.uuid, logoutEvent.sessionId) + } else { + sessionPersister.logoutOtherSessions(logoutEvent.uuid, logoutEvent.sessionId) + } + } +} diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/UserSessionDeviceService.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/UserSessionDeviceService.kt new file mode 100644 index 000000000..bd28282a4 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/service/UserSessionDeviceService.kt @@ -0,0 +1,47 @@ +package co.nilin.opex.device.core.service + +import co.nilin.opex.device.core.data.* +import co.nilin.opex.device.core.spi.DevicePersister +import co.nilin.opex.device.core.spi.SessionPersister +import co.nilin.opex.device.core.spi.UserDevicePersister +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.* + +@Service +class UserSessionDeviceService( + private val devicePersister: DevicePersister, + private val userDevicePersister: UserDevicePersister, + private val sessionPersister: SessionPersister +) { + + suspend fun getUserSessionsWithDevices(sessionsRequest: SessionsRequest): List { + return sessionPersister.fetchUserDeviceSession( sessionsRequest) + } + + @Transactional + suspend fun upsertDevice(loginEvent: LoginEvent) { + val device = with(loginEvent) { + devicePersister.upsertDevice( + Device( + deviceUuid = this.deviceUuid ?: UUID.randomUUID().toString(), + os = this.os, + osVersion = this.osVersion, + appVersion = this.appVersion, + pushToken = this.pushToken, + ) + ) + } + userDevicePersister.linkDeviceToUser(loginEvent.uuid, device?.id!!) + sessionPersister.createOrUpdateSession( + Session( + sessionState = loginEvent.sessionId, + userId = loginEvent.uuid, + deviceId = device.id, + expireDate = loginEvent.expireDate + ) + ) + } + + suspend fun getAllDevicesOfUser(userId: String) = userDevicePersister.fetchUserDevices(userId) +} diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/DevicePersister.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/DevicePersister.kt new file mode 100644 index 000000000..1c28d66f6 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/DevicePersister.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.device.core.spi + +import co.nilin.opex.device.core.data.Device +import co.nilin.opex.device.core.data.UserDevice +import reactor.core.publisher.Mono + +interface DevicePersister { + + suspend fun upsertDevice(device: Device): Device? + + suspend fun fetchDeviceByUuid(deviceUuid: String): Device? + + suspend fun fetchDevicesByIds(deviceIds: List): List + + suspend fun fetchAllDevices(): List +} \ No newline at end of file diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/LoginEventListener.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/LoginEventListener.kt new file mode 100644 index 000000000..7f7a01cb9 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/LoginEventListener.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.device.core.spi + +import co.nilin.opex.device.core.data.LoginEvent + + +interface LoginEventListener { + fun id(): String + fun onEvent(event: LoginEvent, partition: Int, offset: Long, timestamp: Long, eventId: String) + } diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/LogoutEventListener.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/LogoutEventListener.kt new file mode 100644 index 000000000..2a1e61409 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/LogoutEventListener.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.device.core.spi + +import co.nilin.opex.device.core.data.LogoutEvent + + +interface LogoutEventListener { + fun id(): String + fun onEvent(event: LogoutEvent, partition: Int, offset: Long, timestamp: Long, eventId: String) + } diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/SessionPersister.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/SessionPersister.kt new file mode 100644 index 000000000..5c6fb7332 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/SessionPersister.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.device.core.spi + +import co.nilin.opex.device.core.data.Session +import co.nilin.opex.device.core.data.SessionStatus +import co.nilin.opex.device.core.data.SessionsRequest +import co.nilin.opex.device.core.data.UserSessionDevice +import java.time.LocalDateTime + + +interface SessionPersister { + + suspend fun createOrUpdateSession(session: Session): Session? + + suspend fun fetchSessionByState(sessionState: String): Session? + + suspend fun fetchUserDeviceSession(sessionsRequest: SessionsRequest): List + + suspend fun fetchActiveSessions(userId: String): List + + suspend fun updateSessionStatus(sessionState: String, status: SessionStatus): Boolean + + suspend fun findExpiredSessions(before: LocalDateTime): List + + suspend fun logoutSessionByState(userId: String, sessionState: String): Boolean + + suspend fun logoutOtherSessions(userId: String, currentSessionState: String) + suspend fun markExpiredSessions(): Int + +} \ No newline at end of file diff --git a/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/UserDevicePersister.kt b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/UserDevicePersister.kt new file mode 100644 index 000000000..129749ad3 --- /dev/null +++ b/device-management/device-management-core/src/main/kotlin/co/nilin/opex/device/core/spi/UserDevicePersister.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.device.core.spi + +import co.nilin.opex.device.core.data.UserDevice +import java.time.LocalDateTime + +interface UserDevicePersister { + suspend fun linkDeviceToUser( + userId: String, + deviceId: Long, + firstLoginDate: LocalDateTime = LocalDateTime.now() + ): UserDevice? + + suspend fun fetchUserDevice(userId: String, deviceId: Long): UserDevice? + + suspend fun fetchUserDevices(userId: String): List + + suspend fun updateLastUsed( + userId: String, + deviceId: Long, + lastLoginDate: LocalDateTime = LocalDateTime.now() + ): Boolean + + suspend fun unlinkDeviceFromUser(userId: String, deviceId: Long): Boolean +} \ No newline at end of file diff --git a/device-management/device-management-ports/device-management-kafka-listener/.gitignore b/device-management/device-management-ports/device-management-kafka-listener/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/device-management/device-management-ports/device-management-kafka-listener/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/device-management/device-management-ports/device-management-kafka-listener/pom.xml b/device-management/device-management-ports/device-management-kafka-listener/pom.xml new file mode 100644 index 000000000..f553ecb30 --- /dev/null +++ b/device-management/device-management-ports/device-management-kafka-listener/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + co.nilin.opex.device-management + device-management + 1.0.1-beta.7 + ../../pom.xml + + + co.nilin.opex.device-management.ports + device-management-kafka-listener + device-management-kafka-listener + device-management-kafka + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.springframework.boot + spring-boot-starter-test + test + + + co.nilin.opex.device-management + device-management-core + + + + diff --git a/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/config/KafkaListenerConfig.kt b/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/config/KafkaListenerConfig.kt new file mode 100644 index 000000000..ba83f1cb5 --- /dev/null +++ b/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/config/KafkaListenerConfig.kt @@ -0,0 +1,126 @@ +package co.nilin.opex.device.ports.kafka.config + + +import co.nilin.opex.device.core.data.LoginEvent +import co.nilin.opex.device.core.data.LogoutEvent +import co.nilin.opex.device.core.data.SessionEvent +import co.nilin.opex.device.ports.kafka.consumer.LoginKafkaListener +import co.nilin.opex.device.ports.kafka.consumer.LogoutKafkaListener +import org.apache.commons.logging.Log +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.serialization.StringDeserializer +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.* +import org.springframework.kafka.listener.* +import org.springframework.kafka.support.serializer.JsonDeserializer +import org.springframework.util.backoff.FixedBackOff +import java.util.regex.Pattern + + +@Configuration +class KafkaListenerConfig { + private val logger = LoggerFactory.getLogger(KafkaListenerConfig::class.java) + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean("consumerConfigs") + fun consumerConfigs(): Map { + + return mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, + JsonDeserializer.TRUSTED_PACKAGES to "co.nilin.opex.*", + JsonDeserializer.TYPE_MAPPINGS to "loginEvent:co.nilin.opex.device.core.data.LoginEvent,logoutEvent:co.nilin.opex.device.core.data.LogoutEvent" + + ) + } + + + @Bean("loginConsumerFactory") + fun loginConsumerFactory(@Qualifier("consumerConfigs") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + + @Bean("loginProducerFactory") + fun loginProducerFactory(@Qualifier("consumerConfigs") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean("loginKafkaTemplate") + fun loginKafkaTemplate(@Qualifier("loginProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + + + @Bean("logoutConsumerFactory") + fun logoutConsumerFactory(@Qualifier("consumerConfigs") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + + @Bean("logoutProducerFactory") + fun logoutProducerFactory(@Qualifier("consumerConfigs") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean("logoutKafkaTemplate") + fun logoutKafkaTemplate(@Qualifier("logoutProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + + + + @Bean + @ConditionalOnBean(LoginKafkaListener::class) + fun configureLoginListener( + listener: LoginKafkaListener, + @Qualifier("loginKafkaTemplate") template: KafkaTemplate, + @Qualifier("loginConsumerFactory") consumerFactory: ConsumerFactory + ): ConcurrentMessageListenerContainer { + val containerProps = ContainerProperties(Pattern.compile("login")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("LoginKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "login.DLT") + container.start() + return container + } + + @Bean + @ConditionalOnBean(LogoutKafkaListener::class) + fun configureLogoutListener( + listener: LogoutKafkaListener, + @Qualifier("logoutKafkaTemplate") template: KafkaTemplate, + @Qualifier("logoutConsumerFactory") consumerFactory: ConsumerFactory + ): ConcurrentMessageListenerContainer { + val containerProps = ContainerProperties(Pattern.compile("logout")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("logoutKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "logout.DLT") + container.start() + return container + } + + + + private fun createConsumerErrorHandler(kafkaTemplate: KafkaTemplate<*, *>, dltTopic: String): CommonErrorHandler { + val recoverer = DeadLetterPublishingRecoverer(kafkaTemplate) { cr, _ -> + cr.headers().add("dlt-origin-module", "DEVICE-MANAGEMENT".toByteArray()) + TopicPartition(dltTopic, cr.partition()) + } + return DefaultErrorHandler(recoverer, FixedBackOff(5_000, 20)) + } + +} \ No newline at end of file diff --git a/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/consumer/LoginKafkaListener.kt b/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/consumer/LoginKafkaListener.kt new file mode 100644 index 000000000..c21003022 --- /dev/null +++ b/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/consumer/LoginKafkaListener.kt @@ -0,0 +1,34 @@ +package co.nilin.opex.device.ports.kafka.consumer + +import co.nilin.opex.device.core.data.LoginEvent +import co.nilin.opex.device.core.spi.LoginEventListener +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.listener.MessageListener +import org.springframework.stereotype.Component + +@Component +class LoginKafkaListener : MessageListener { + val eventListeners = arrayListOf() + private val logger = LoggerFactory.getLogger(LoginKafkaListener::class.java) + override fun onMessage(data: ConsumerRecord) { + + eventListeners.forEach { tl -> + logger.info("incoming new event " + tl.id()) + tl.onEvent(data.value(), data.partition(), data.offset(), data.timestamp(), tl.id()) + } + } + + fun addEventListener(tl: LoginEventListener) { + eventListeners.add(tl) + } + + fun removeEventListener(tl: LoginEventListener) { + eventListeners.removeIf { item -> + item.id() == tl.id() + } + + } + + +} \ No newline at end of file diff --git a/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/consumer/LogoutKafkaListener.kt b/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/consumer/LogoutKafkaListener.kt new file mode 100644 index 000000000..ec152b6d1 --- /dev/null +++ b/device-management/device-management-ports/device-management-kafka-listener/src/main/kotlin/co/nilin/opex/device/ports/kafka/consumer/LogoutKafkaListener.kt @@ -0,0 +1,36 @@ +package co.nilin.opex.device.ports.kafka.consumer + +import co.nilin.opex.device.core.data.LoginEvent +import co.nilin.opex.device.core.data.LogoutEvent +import co.nilin.opex.device.core.spi.LoginEventListener +import co.nilin.opex.device.core.spi.LogoutEventListener +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.listener.MessageListener +import org.springframework.stereotype.Component + +@Component +class LogoutKafkaListener : MessageListener { + val eventListeners = arrayListOf() + private val logger = LoggerFactory.getLogger(LogoutKafkaListener::class.java) + override fun onMessage(data: ConsumerRecord) { + + eventListeners.forEach { tl -> + logger.info("incoming new event " + tl.id()) + tl.onEvent(data.value(), data.partition(), data.offset(), data.timestamp(), tl.id()) + } + } + + fun addEventListener(tl: LogoutEventListener) { + eventListeners.add(tl) + } + + fun removeEventListener(tl: LogoutEventListener) { + eventListeners.removeIf { item -> + item.id() == tl.id() + } + + } + + +} \ No newline at end of file diff --git a/device-management/device-management-ports/device-management-kafka-listener/src/main/resources/application.properties b/device-management/device-management-ports/device-management-kafka-listener/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/device-management/device-management-ports/device-management-kafka-listener/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/device-management/device-management-ports/device-management-kafka-listener/src/test/kotlin/co/nilin/opex/profile/ports/kafka/ProfilePostgressApplicationTests.kt b/device-management/device-management-ports/device-management-kafka-listener/src/test/kotlin/co/nilin/opex/profile/ports/kafka/ProfilePostgressApplicationTests.kt new file mode 100644 index 000000000..577be1be0 --- /dev/null +++ b/device-management/device-management-ports/device-management-kafka-listener/src/test/kotlin/co/nilin/opex/profile/ports/kafka/ProfilePostgressApplicationTests.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.kafka + +//@SpringBootTest +//class ProfilePostgressApplicationTests { +// +// @Test +// fun contextLoads() { +// } +// +//} diff --git a/device-management/device-management-ports/device-management-postgres/pom.xml b/device-management/device-management-ports/device-management-postgres/pom.xml new file mode 100644 index 000000000..eda425445 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + co.nilin.opex.device-management + device-management + 1.0.1-beta.7 + ../../pom.xml + + + co.nilin.opex.device-management.ports + device-management-postgres + device-management-postgres + Device management Postgres adapter + + + + co.nilin.opex.device-management + device-management-core + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + org.postgresql + r2dbc-postgresql + runtime + + + org.postgresql + postgresql + 42.7.3 + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.flywaydb + flyway-database-postgresql + + + org.flywaydb + flyway-core + + + \ No newline at end of file diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/config/PostgresConfig.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/config/PostgresConfig.kt new file mode 100644 index 000000000..08a864588 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/config/PostgresConfig.kt @@ -0,0 +1,50 @@ +package co.nilin.opex.device.ports.postgres.config + +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories + +@Configuration +@EnableR2dbcRepositories(basePackages = ["co.nilin.opex"]) +class PostgresConfig { + private val logger = LoggerFactory.getLogger(PostgresConfig::class.java) + + init { + logger.info("🔍 PostgresConfig loaded") + } + @Bean + fun flywayConfig(@Value("\${spring.datasource.url}") url: String, + @Value("\${spring.datasource.username}") user: String, + @Value("\${spring.datasource.password}") password: String): Flyway? { + + val flyway: Flyway = Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(false) + .load() + try { + retry(6, 5000) { + flyway.migrate() + } + } catch (e: Exception) { + logger.error("❌ Flyway migration failed", e) + } + return flyway + } + + fun retry(times: Int, delayMs: Long, block: () -> Unit) { + var attempt = 0 + while (true) { + try { + block() + return + } catch (e: Exception) { + if (++attempt >= times) throw e + Thread.sleep(delayMs) + } + } + } +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/DeviceRepository.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/DeviceRepository.kt new file mode 100644 index 000000000..2ee72ce07 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/DeviceRepository.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.device.ports.postgres.dao + + +import co.nilin.opex.device.ports.postgres.model.DeviceModel +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono +@Repository +interface DeviceRepository : R2dbcRepository { + fun findByDeviceUuid(deviceUuid: String): Mono +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/SessionRepository.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/SessionRepository.kt new file mode 100644 index 000000000..f6dacb180 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/SessionRepository.kt @@ -0,0 +1,62 @@ +package co.nilin.opex.device.ports.postgres.dao + + +import co.nilin.opex.device.core.data.SessionStatus +import co.nilin.opex.device.core.data.UserSessionDevice +import co.nilin.opex.device.ports.postgres.model.SessionModel +import org.springframework.data.domain.Pageable +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.LocalDateTime + +@Repository +interface SessionRepository : R2dbcRepository { + fun findBySessionState(sessionState: String): Mono + fun findByUserId(userId: String): Flux + fun findByUserIdAndStatus(userId: String, status: SessionStatus): Flux + fun findByUserIdAndSessionState(userId: String, sessionState: String): Mono + fun findByStatusAndExpireDateBefore(status: SessionStatus, before: LocalDateTime): Flux + + @Query("update sessions set status='TERMINATED' where uuid=:userId and session_state !=:currentSessionState and status='ACTIVE'") + fun logoutAllUserSessionExceptCurrent(userId: String, currentSessionState: String): Mono + fun findAllByStatusAndExpireDateLessThan( + status: SessionStatus, + dateTime: LocalDateTime + ): Flux + + @Query( + """ + SELECT + d.device_uuid, + d.os, + d.os_version, + d.app_version, + ud.first_login_date, + ud.last_login_date, + s.session_state, + s.status as session_status, + s.create_date as session_create_date, + s.expire_date as session_expire_date + FROM user_devices ud + JOIN devices d ON ud.device_id = d.id + LEFT JOIN sessions s + ON s.device_id = ud.device_id AND s.uuid = ud.uuid + WHERE ud.uuid = :userId + AND (:os IS NULL OR d.os = :os) + AND (:status IS NULL OR s.status = :status) + order by CASE WHEN :ascendingByTime=true THEN s.create_date END ASC, + CASE WHEN :ascendingByTime=false THEN s.create_date END DESC + """ + ) + fun findUserSessionsWithDevices( + @Param("userId") userId: String, + @Param("os") os: String?, + @Param("status") status: String?, + @Param("ascendingByTime") ascendingByTime: Boolean, + pageable: Pageable + ): Flux +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/UserDeviceRepository.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/UserDeviceRepository.kt new file mode 100644 index 000000000..ce0e2fda8 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/dao/UserDeviceRepository.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.device.ports.postgres.dao + + +import co.nilin.opex.device.ports.postgres.model.UserDeviceModel +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +@Repository +interface UserDeviceRepository : R2dbcRepository { + fun findByUserIdAndDeviceId(userId: String, deviceId: Long): Mono + fun findByUserId(userId: String): Flux +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/DeviceManagerImpl.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/DeviceManagerImpl.kt new file mode 100644 index 000000000..545b72c93 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/DeviceManagerImpl.kt @@ -0,0 +1,64 @@ +package co.nilin.opex.device.ports.postgres.impl + + +import co.nilin.opex.device.core.data.Device +import co.nilin.opex.device.core.spi.DevicePersister +import co.nilin.opex.device.ports.postgres.dao.DeviceRepository +import co.nilin.opex.device.ports.postgres.utils.toDto +import co.nilin.opex.device.ports.postgres.utils.toModel + +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitFirst +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class DeviceManagerImpl( + private val deviceRepository: DeviceRepository +) : DevicePersister { + + private val logger = LoggerFactory.getLogger(DeviceManagerImpl::class.java) + + override suspend fun upsertDevice(device: Device): Device? { + val existing = deviceRepository.findByDeviceUuid(device.deviceUuid).awaitFirstOrNull() + val now = LocalDateTime.now() + val savedModel = if (existing != null) { + deviceRepository.save( + existing.copy( + os = device.os, + osVersion = device.osVersion, + appVersion = device.appVersion, + pushToken = device.pushToken, + lastUpdateDate = now + ) + ).awaitFirst() + } else { + deviceRepository.save(device.toModel()).awaitFirst() + } + + return savedModel.toDto() + } + + override suspend fun fetchDeviceByUuid(deviceUuid: String): Device? { + return deviceRepository.findByDeviceUuid(deviceUuid) + .awaitFirstOrNull() + ?.toDto() + } + + override suspend fun fetchDevicesByIds(deviceIds: List): List { + return deviceRepository.findAllById(deviceIds) + .map { it.toDto() } + .collectList() + .awaitFirst() + } + + override suspend fun fetchAllDevices(): List { + return deviceRepository.findAll() + .map { it.toDto() } + .collectList() + .awaitFirst() + } + + +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/SessionManagerImpl.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/SessionManagerImpl.kt new file mode 100644 index 000000000..6452bd7fb --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/SessionManagerImpl.kt @@ -0,0 +1,112 @@ +package co.nilin.opex.device.ports.postgres.impl + + +import co.nilin.opex.device.core.data.Session +import co.nilin.opex.device.core.data.SessionStatus +import co.nilin.opex.device.core.data.SessionsRequest +import co.nilin.opex.device.core.data.UserSessionDevice +import co.nilin.opex.device.core.spi.SessionPersister +import co.nilin.opex.device.ports.postgres.dao.SessionRepository +import co.nilin.opex.device.ports.postgres.utils.toDto +import co.nilin.opex.device.ports.postgres.utils.toModel +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactor.awaitSingle +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class SessionManagerImpl( + private val sessionRepository: SessionRepository +) : SessionPersister { + + private val logger = LoggerFactory.getLogger(SessionManagerImpl::class.java) + + override suspend fun createOrUpdateSession(session: Session): Session? { + val newOrUpdatedSession = sessionRepository.findBySessionState(session.sessionState) + .awaitFirstOrNull()?.copy( + expireDate = session.expireDate, + status = SessionStatus.ACTIVE + ) ?: session.toModel() + sessionRepository.save(newOrUpdatedSession).awaitSingle() + return newOrUpdatedSession.toDto() + } + + override suspend fun fetchSessionByState(sessionState: String): Session? { + return sessionRepository.findBySessionState(sessionState) + .awaitFirstOrNull() + ?.toDto() + } + + override suspend fun fetchUserDeviceSession( + sessionsRequest: SessionsRequest + ): List { + val pageable: Pageable = PageRequest.of(sessionsRequest.offset, sessionsRequest.limit) + return sessionRepository.findUserSessionsWithDevices( + sessionsRequest.uuid, + sessionsRequest.os?.name, + sessionsRequest.status?.name, + sessionsRequest.ascendingByTime, + pageable + ).collectList().awaitSingle() + } + + override suspend fun fetchActiveSessions(userId: String): List { + return sessionRepository.findByUserIdAndStatus(userId, SessionStatus.ACTIVE) + .map { it.toDto() } + .collectList() + .awaitFirst() + } + + override suspend fun updateSessionStatus(sessionState: String, status: SessionStatus): Boolean { + val session = sessionRepository.findBySessionState(sessionState).awaitFirstOrNull() + return if (session != null) { + sessionRepository.save(session.copy(status = status)).awaitFirstOrNull() + true + } else { + false + } + } + + override suspend fun findExpiredSessions(before: LocalDateTime): List { + return sessionRepository.findByStatusAndExpireDateBefore(SessionStatus.ACTIVE, before) + .map { it.toDto() } + .collectList() + .awaitFirst() + } + + override suspend fun logoutSessionByState(userId: String, sessionState: String): Boolean { + val session = sessionRepository.findByUserIdAndSessionState(userId, sessionState).awaitFirstOrNull() + return if (session != null) { + sessionRepository.save(session.copy(status = SessionStatus.TERMINATED)).awaitFirstOrNull() + true + } else { + false + } + } + + override suspend fun logoutOtherSessions(userId: String, currentSessionState: String) { + sessionRepository.logoutAllUserSessionExceptCurrent(userId, currentSessionState).awaitFirstOrNull() + } + + override suspend fun markExpiredSessions(): Int { + val now = LocalDateTime.now() +//need an interval + val expiredSessions = sessionRepository + .findAllByStatusAndExpireDateLessThan(SessionStatus.ACTIVE, now) + .collectList() + .awaitSingle() + + expiredSessions.forEach { session -> + sessionRepository.save( + session.copy(status = SessionStatus.EXPIRED) + ).awaitSingle() + } + + logger.info("Expired ${expiredSessions.size} sessions") + return expiredSessions.size + } +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/UserDeviceManagerImpl.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/UserDeviceManagerImpl.kt new file mode 100644 index 000000000..4b2b4f240 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/impl/UserDeviceManagerImpl.kt @@ -0,0 +1,75 @@ +package co.nilin.opex.device.ports.postgres.impl + +import co.nilin.opex.device.core.data.UserDevice +import co.nilin.opex.device.core.spi.UserDevicePersister +import co.nilin.opex.device.ports.postgres.dao.UserDeviceRepository +import co.nilin.opex.device.ports.postgres.utils.toDto +import co.nilin.opex.device.ports.postgres.utils.toModel +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitFirst +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class UserDeviceManagerImpl( + private val userDeviceRepository: UserDeviceRepository +) : UserDevicePersister { + + private val logger = LoggerFactory.getLogger(UserDeviceManagerImpl::class.java) + + override suspend fun linkDeviceToUser( + userId: String, + deviceId: Long, + firstLoginDate: LocalDateTime + ): UserDevice? { + val existing = userDeviceRepository.findByUserIdAndDeviceId(userId, deviceId).awaitFirstOrNull() + return if (existing != null) { + // Update firstLoginDate only if null + val updated = existing.copy( + firstLoginDate = existing.firstLoginDate ?: firstLoginDate, + lastLoginDate = LocalDateTime.now() + ) + userDeviceRepository.save(updated).awaitFirst().toDto() + } else { + val newRecord = UserDevice(userId, deviceId, firstLoginDate, LocalDateTime.now()) + userDeviceRepository.save(newRecord.toModel()).awaitFirst().toDto() + } + } + + override suspend fun fetchUserDevice(userId: String, deviceId: Long): UserDevice? { + return userDeviceRepository.findByUserIdAndDeviceId(userId, deviceId) + .awaitFirstOrNull() + ?.toDto() + } + + override suspend fun fetchUserDevices(userId: String): List { + return userDeviceRepository.findByUserId(userId) + .map { it.toDto() } + .collectList() + .awaitFirst() + } + + override suspend fun updateLastUsed(userId: String, deviceId: Long, lastLoginDate: LocalDateTime): Boolean { + val existing = userDeviceRepository.findByUserIdAndDeviceId(userId, deviceId).awaitFirstOrNull() + return if (existing != null) { + val updated = existing.copy(lastLoginDate = lastLoginDate) + userDeviceRepository.save(updated).awaitFirst() + true + } else { + false + } + } + + override suspend fun unlinkDeviceFromUser(userId: String, deviceId: Long): Boolean { + return try { + userDeviceRepository.findByUserIdAndDeviceId(userId, deviceId) + .awaitFirstOrNull() + ?.let { userDeviceRepository.delete(it).awaitFirstOrNull() } + true + } catch (ex: Exception) { + logger.error("Failed to unlink device $deviceId from user $userId", ex) + false + } + } +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/DeviceModel.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/DeviceModel.kt new file mode 100644 index 000000000..53405eba3 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/DeviceModel.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.device.ports.postgres.model + +import co.nilin.opex.device.core.data.Os +import org.springframework.data.annotation.Id +import org.springframework.data.annotation.Version +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + + +@Table(name = "devices") +data class DeviceModel( + @Id val id: Long?=null, + @Column("device_uuid") val deviceUuid: String, + @Column("os") val os: Os?, + @Column("os_version") val osVersion: String?, + @Column("app_version") val appVersion: String?, + @Column("push_token") val pushToken: String?, + @Column("create_date") val creteDate: LocalDateTime? = LocalDateTime.now(), + @Column("last_update_date") val lastUpdateDate: LocalDateTime? = LocalDateTime.now(), + @Column @Version var version: Long? = null +) diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/SessionModel.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/SessionModel.kt new file mode 100644 index 000000000..937087453 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/SessionModel.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.device.ports.postgres.model + +import co.nilin.opex.device.core.data.SessionStatus +import org.springframework.data.annotation.Id +import org.springframework.data.annotation.Version +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("sessions") +data class SessionModel( + @Id val id: Long? = null, + @Column("session_state") val sessionState: String, + @Column("uuid") val userId: String, + @Column("device_id") val deviceId: Long, + @Column("status") val status: SessionStatus, + @Column("create_date") val createDate: LocalDateTime? = LocalDateTime.now(), + @Column("expire_date") val expireDate: LocalDateTime? = LocalDateTime.now(), + @Column @Version var version: Long? = null +) + diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/UserDeviceModel.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/UserDeviceModel.kt new file mode 100644 index 000000000..7d767fdf7 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/model/UserDeviceModel.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.device.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + + +@Table("user_devices") +data class UserDeviceModel( + @Id val id: Long? = null, + @Column("uuid") val userId: String, + @Column("device_id") val deviceId: Long, + @Column("first_login_date") val firstLoginDate: LocalDateTime? = LocalDateTime.now(), + @Column("last_login_date") val lastLoginDate: LocalDateTime? = LocalDateTime.now() +) \ No newline at end of file diff --git a/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/utils/Convertor.kt b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/utils/Convertor.kt new file mode 100644 index 000000000..e7f17a17f --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/kotlin/co/nilin/opex/device/ports/postgres/utils/Convertor.kt @@ -0,0 +1,75 @@ +package co.nilin.opex.device.ports.postgres.utils + + +import co.nilin.opex.device.core.data.Device +import co.nilin.opex.device.core.data.Session +import co.nilin.opex.device.core.data.UserDevice +import co.nilin.opex.device.ports.postgres.model.DeviceModel +import co.nilin.opex.device.ports.postgres.model.SessionModel +import co.nilin.opex.device.ports.postgres.model.UserDeviceModel +import java.time.LocalDateTime + +fun Device.toModel(): DeviceModel { + return DeviceModel( + deviceUuid = deviceUuid, + os = os, + osVersion = osVersion, + appVersion = appVersion, + pushToken = pushToken, + creteDate = createDate ?: LocalDateTime.now(), + lastUpdateDate = lastUpdateDate ?: LocalDateTime.now() + ) +} + +fun DeviceModel.toDto(): Device { + return Device( + id = id, + deviceUuid = deviceUuid, + os = os, + osVersion = osVersion, + appVersion = appVersion, + pushToken = pushToken, + createDate = creteDate, + lastUpdateDate = lastUpdateDate + ) +} + +fun UserDevice.toModel(): UserDeviceModel { + return UserDeviceModel( + userId = userId, + deviceId = deviceId, + firstLoginDate = firstLoginDate ?: LocalDateTime.now(), + lastLoginDate = lastLoginDate ?: LocalDateTime.now() + ) +} + +fun UserDeviceModel.toDto(): UserDevice { + return UserDevice( + userId = userId, + deviceId = deviceId, + firstLoginDate = firstLoginDate, + lastLoginDate = lastLoginDate + ) +} + +fun Session.toModel(): SessionModel { + return SessionModel( + sessionState = sessionState, + userId = userId, + deviceId = deviceId, + status = status, + createDate = createDate ?: LocalDateTime.now(), + expireDate = expireDate + ) +} + +fun SessionModel.toDto(): Session { + return Session( + sessionState = sessionState, + userId = userId, + deviceId = deviceId, + status = status, + createDate = createDate, + expireDate = expireDate + ) +} diff --git a/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V1__create_tables.sql b/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V1__create_tables.sql new file mode 100644 index 000000000..bfe463eaa --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V1__create_tables.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS devices +( + id BIGSERIAL PRIMARY KEY, + device_uuid VARCHAR(255) NOT NULL UNIQUE, + os VARCHAR(50), + os_version VARCHAR(50), + app_version VARCHAR(50), + push_token VARCHAR(255), + create_date TIMESTAMP NOT NULL DEFAULT NOW(), + last_update_date TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_devices +( + id BIGSERIAL PRIMARY KEY, + uuid VARCHAR(255) NOT NULL, + device_id BIGINT NOT NULL, + first_login_date TIMESTAMP NOT NULL DEFAULT NOW(), + last_login_date TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT fk_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE, + CONSTRAINT uc_user_device UNIQUE (uuid, device_id) +); + + +CREATE TABLE IF NOT EXISTS sessions +( + id BIGSERIAL PRIMARY KEY, + session_state VARCHAR(255) NOT NULL UNIQUE, + uuid VARCHAR(255) NOT NULL, + device_id BIGINT NOT NULL, + status VARCHAR(15) NOT NULL DEFAULT 'ACTIVE', + create_date TIMESTAMP NOT NULL DEFAULT NOW(), + expire_date TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT fk_session_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE +); diff --git a/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V2__create_version_column_for_device.sql b/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V2__create_version_column_for_device.sql new file mode 100644 index 000000000..b8a1d0eee --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V2__create_version_column_for_device.sql @@ -0,0 +1 @@ +Alter TABLE devices add column version BIGINT default 0; \ No newline at end of file diff --git a/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V3__create_version_column_for_session.sql b/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V3__create_version_column_for_session.sql new file mode 100644 index 000000000..7efbfb802 --- /dev/null +++ b/device-management/device-management-ports/device-management-postgres/src/main/resources/db/migration/V3__create_version_column_for_session.sql @@ -0,0 +1 @@ +Alter TABLE sessions add column version BIGINT default 0; \ No newline at end of file diff --git a/device-management/pom.xml b/device-management/pom.xml new file mode 100644 index 000000000..fc80e43f7 --- /dev/null +++ b/device-management/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + + core + co.nilin.opex + 1.0.1-beta.7 + + + co.nilin.opex.device-management + device-management + device-management + pom + Device Management root of Opex + + + 21 + 21 + 21 + 2.1.0 + 3.4.2 + 2024.0.0 + 1.2.25 + + + + + device-management-app + device-management-core + device-management-ports/device-management-kafka-listener + device-management-ports/device-management-postgres + + + + + org.springframework.boot + spring-boot-starter-test + + + org.springframework.kafka + spring-kafka + 3.3.2 + + + co.nilin.opex + common + + + + + + + co.nilin.opex.utility + error-handler + ${error-hanlder.version} + + + co.nilin.opex.utility + interceptors + ${interceptor.version} + + + co.nilin.opex.device-management + device-management-core + ${project.version} + + + co.nilin.opex.device-management.ports + device-management-kafka-listener + ${project.version} + + + co.nilin.opex.device-management.ports + device-management-postgres + ${project.version} + + + + diff --git a/docker-compose-otc.override.yml b/docker-compose-otc.override.yml index 8d620a5f6..fe920a84e 100644 --- a/docker-compose-otc.override.yml +++ b/docker-compose-otc.override.yml @@ -8,13 +8,9 @@ services: wallet: build: wallet/wallet-app volumes: - - "./preferences-dev.yml:/preferences.yml" - "./drive-key.json:/drive-key.json" vault: build: docker-images/vault bc-gateway: build: bc-gateway/bc-gateway-app - volumes: - - "./preferences-dev.yml:/preferences.yml" - diff --git a/docker-compose-otc.yml b/docker-compose-otc.yml index 59bbbec0b..3bf37981c 100644 --- a/docker-compose-otc.yml +++ b/docker-compose-otc.yml @@ -29,14 +29,18 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - - PREFERENCES=$PREFERENCES - DRIVE_FOLDER_ID=$DRIVE_FOLDER_ID - - BACKUP_ENABLED=$WALLET_BACKUP_ENABLED + - STORAGE_FOLDER_ID=$STORAGE_FOLDER_ID + - BACKUP_ENABLED_STORAGE=$WALLET_BACKUP_ENABLED_STORAGE + - BACKUP_ENABLED_GOOGLE_DRIVE=$WALLET_BACKUP_ENABLED_GOOGLE_DRIVE - SPRING_PROFILES_ACTIVE=otc - - auth_url=${AUTH_URL} - - auth_jwk_endpoint=${JWK_ENDPOINT} - configs: - - preferences.yml + - AUTH_URL=${AUTH_URL} + - AUTH_JWK_ENDPOINT=${JWK_ENDPOINT} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: - postgres-wallet - vault @@ -51,6 +55,8 @@ services: deploy: restart_policy: condition: on-failure + volumes: + - documents:/Documents vault: image: ghcr.io/opexdev/vault-opex:${TAG} volumes: @@ -70,7 +76,6 @@ services: - VANDAR_API_KEY=$VANDAR_API_KEY - CLIENT_SECRET=${CLIENT_SECRET} - CLIENT_ID=${CLIENT_ID} - cap_add: - IPC_LOCK deploy: @@ -88,15 +93,16 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - - PREFERENCES=$PREFERENCES - - ADDRESS_EXP_TIME=120 + - ADDRESS_EXP_TIME=7200 - SPRING_PROFILES_ACTIVE=otc - - auth_url=${AUTH_URL} - - auth_jwk_endpoint=${JWK_ENDPOINT} - dns: - - "8.8.8.8" - configs: - - preferences.yml + - AUTH_URL=${AUTH_URL} + - AUTH_JWK_ENDPOINT=${JWK_ENDPOINT} + - OMNI_URL=${OMNI_URL} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: - vault - postgres-bc-gateway @@ -114,12 +120,24 @@ services: <<: *postgres-db volumes: - bc-gateway-data:/var/lib/postgresql/data/ - - + redis-cache: + image: redis/redis-stack:latest + volumes: + - redis-cache-data:/data + environment: + - REDIS_REPLICATION_MODE=master + networks: + - default + deploy: + restart_policy: + condition: on-failure volumes: wallet-data: vault-data: bc-gateway-data: + documents: + redis-cache-data: + networks: default: @@ -131,6 +149,3 @@ secrets: file: opex.dev.crt private_pem: file: private.pem -configs: - preferences.yml: - file: preferences.yml diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 00ed11152..b1a4aa28a 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -9,6 +9,9 @@ services: kafka-3: image: ghcr.io/opexdev/kafka:$TAG build: docker-images/kafka + keycloak: + image: ghcr.io/opexdev/keycloak:$TAG + build: auth-gateway/keycloak-setup vault: image: ghcr.io/opexdev/vault-opex:$TAG build: docker-images/vault @@ -33,6 +36,9 @@ services: auth: image: ghcr.io/opexdev/auth:$TAG build: user-management/keycloak-gateway + auth-gateway: + image: ghcr.io/opexdev/auth-gateway:$TAG + build: auth-gateway/auth-gateway-app wallet: image: ghcr.io/opexdev/wallet:$TAG build: wallet/wallet-app @@ -45,3 +51,12 @@ services: bc-gateway: image: ghcr.io/opexdev/bc-gateway:$TAG build: bc-gateway/bc-gateway-app + otp: + image: ghcr.io/opexdev/otp:$TAG + build: otp/otp-app + profile: + image: ghcr.io/opexdev/profile:$TAG + build: profile/profile-app + device-management: + image: ghcr.io/opexdev/device-management:$TAG + build: device-management/device-management-app \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml index e9c9f0431..3ab397629 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,7 +1,5 @@ version: '3.8' services: - zookeeper: - user: "root" kafka-1: user: "root" kafka-2: @@ -22,9 +20,25 @@ services: redis-cache: ports: - "6380:6379" + keycloak: + ports: + - "8193:8080" akhq: ports: - "127.0.0.1:10100:8080" + auth-gateway: + ports: + - "8184:8080" + - "127.0.0.1:1055:5005" + postgres-keycloak: + ports: + - "127.0.0.1:5461:5432" + postgres-otp: + ports: + - "127.0.0.1:5462:5432" + postgres-market: + ports: + - "127.0.0.1:5438:5432" accountant: ports: - "127.0.0.1:8089:8080" @@ -60,3 +74,19 @@ services: ports: - "0.0.0.0:8095:8080" - "127.0.0.1:1052:5005" + otp: + ports: + - "0.0.0.0:8097:8080" + - "127.0.0.1:1053:5005" + profile: + ports: + - "0.0.0.0:8098:8080" + - "127.0.0.1:1054:5005" + device-management: + 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.override.yml b/docker-compose.override.yml index 78bcbb7ca..dba2a02d9 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -2,38 +2,27 @@ version: '3.8' services: accountant: build: accountant/accountant-app - volumes: - - "./preferences-dev.yml:/preferences.yml" eventlog: build: eventlog/eventlog-app matching-engine: build: matching-engine/matching-engine-app - volumes: - - "./preferences-dev.yml:/preferences.yml" matching-engine-duo: build: matching-engine/matching-engine-app - volumes: - - "./preferences-dev.yml:/preferences.yml" matching-gateway: build: matching-gateway/matching-gateway-app auth: build: user-management/keycloak-gateway - volumes: - - "./preferences-dev.yml:/preferences.yml" wallet: build: wallet/wallet-app volumes: - - "./preferences-dev.yml:/preferences.yml" - "./drive-key.json:/drive-key.json" market: build: market/market-app - volumes: - - "./preferences-dev.yml:/preferences.yml" api: build: api/api-app - volumes: - - "./preferences-dev.yml:/preferences.yml" bc-gateway: build: bc-gateway/bc-gateway-app - volumes: - - "./preferences-dev.yml:/preferences.yml" + profile: + build: profile/profile-app + device-management: + build: device-management/device-management-app \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5352c13a4..1c7d6e075 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ x-postgres-db: &postgres-db - POSTGRES_DB=${DB_USER:-opex} - POSTGRES_READ_ONLY_USER=${DB_READ_ONLY_USER:-opex_reader} - POSTGRES_READ_ONLY_PASSWORD=${DB_READ_ONLY_PASS:-hiopex} + healthcheck: + test: [ "CMD", "pg_isready", "-U", "opex" ] + interval: 5s + retries: 10 networks: - default deploy: @@ -14,36 +18,25 @@ x-postgres-db: &postgres-db version: '3.8' services: - zookeeper: - image: confluentinc/cp-zookeeper:7.1.1 - hostname: zookeeper - volumes: - - zookeeper-data:/var/lib/zookeeper/data - - zookeeper-log:/var/lib/zookeeper/log - environment: - - ALLOW_ANONYMOUS_LOGIN=yes - - ZOOKEEPER_CLIENT_PORT=2181 - networks: - - default - deploy: - restart_policy: - condition: on-failure kafka-1: image: ghcr.io/opexdev/kafka hostname: kafka-1 volumes: - kafka-1:/var/lib/kafka/data environment: - - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 + - KAFKA_NODE_ID=1 + - KAFKA_PROCESS_ROLES=broker,controller + - CLUSTER_ID=${KAFKA_CLUSTER_ID} - ALLOW_PLAINTEXT_LISTENER=yes - - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT - - KAFKA_LISTENERS=CLIENT://kafka-1:29092,EXTERNAL://kafka-1:9092 - KAFKA_ADVERTISED_LISTENERS=CLIENT://kafka-1:29092,EXTERNAL://kafka-1:9092 - KAFKA_INTER_BROKER_LISTENER_NAME=CLIENT - KAFKA_UNCLEAN_LEADER_ELECTION_ENABLE=false - KAFKA_OPTS=-javaagent:/opt/prometheus/jmx-exporter.jar=1234:/opt/prometheus/kafka-jmx-exporter.yml - depends_on: - - zookeeper + - KAFKA_CONTROLLER_QUORUM_VOTERS=1@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093 + - KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER + - KAFKA_LISTENERS=CLIENT://kafka-1:29092,EXTERNAL://kafka-1:9092,CONTROLLER://kafka-1:29093 + - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT + networks: - default deploy: @@ -55,16 +48,18 @@ services: volumes: - kafka-2:/var/lib/kafka/data environment: - - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 + - KAFKA_NODE_ID=2 + - KAFKA_PROCESS_ROLES=broker,controller + - CLUSTER_ID=${KAFKA_CLUSTER_ID} - ALLOW_PLAINTEXT_LISTENER=yes - - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT - - KAFKA_LISTENERS=CLIENT://kafka-2:29092,EXTERNAL://kafka-2:9092 - KAFKA_ADVERTISED_LISTENERS=CLIENT://kafka-2:29092,EXTERNAL://kafka-2:9092 - KAFKA_INTER_BROKER_LISTENER_NAME=CLIENT - KAFKA_UNCLEAN_LEADER_ELECTION_ENABLE=false - KAFKA_OPTS=-javaagent:/opt/prometheus/jmx-exporter.jar=1234:/opt/prometheus/kafka-jmx-exporter.yml - depends_on: - - zookeeper + - KAFKA_CONTROLLER_QUORUM_VOTERS=1@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093 + - KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER + - KAFKA_LISTENERS=CLIENT://kafka-2:29092,EXTERNAL://kafka-2:9092,CONTROLLER://kafka-2:29093 + - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT networks: - default deploy: @@ -76,16 +71,19 @@ services: volumes: - kafka-3:/var/lib/kafka/data environment: - - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 + - KAFKA_NODE_ID=3 + - KAFKA_PROCESS_ROLES=broker,controller + - CLUSTER_ID=${KAFKA_CLUSTER_ID} - ALLOW_PLAINTEXT_LISTENER=yes - - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT - - KAFKA_LISTENERS=CLIENT://kafka-3:29092,EXTERNAL://kafka-3:9092 - KAFKA_ADVERTISED_LISTENERS=CLIENT://kafka-3:29092,EXTERNAL://kafka-3:9092 - KAFKA_INTER_BROKER_LISTENER_NAME=CLIENT - KAFKA_UNCLEAN_LEADER_ELECTION_ENABLE=false - KAFKA_OPTS=-javaagent:/opt/prometheus/jmx-exporter.jar=1234:/opt/prometheus/kafka-jmx-exporter.yml - depends_on: - - zookeeper + - KAFKA_CONTROLLER_QUORUM_VOTERS=1@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093 + - KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER + - KAFKA_LISTENERS=CLIENT://kafka-3:29092,EXTERNAL://kafka-3:9092,CONTROLLER://kafka-3:29093 + - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT + networks: - default deploy: @@ -109,6 +107,35 @@ services: deploy: restart_policy: condition: on-failure + keycloak: + image: ghcr.io/opexdev/keycloak + container_name: keycloak + environment: + KEYCLOAK_ADMIN: ${KC_PANEL_USERNAME} + KEYCLOAK_ADMIN_PASSWORD: ${KC_PANEL_PASSWORD} + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/opex + KC_DB_USERNAME: ${DB_USER} + KC_DB_PASSWORD: ${DB_PASS} + KC_HEALTH_ENABLED: true + KC_METRICS_ENABLED: true + GOOGLE_CLIENT_ID: ${KC_GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${KC_GOOGLE_CLIENT_SECRET} + KC_SPI_SCRIPT_UPLOAD_ENABLED: "true" + volumes: + - ./realms:/opt/keycloak/data/import + command: + - start-dev + - --import-realm + - --features=admin-fine-grained-authz,token-exchange,scripts,token-exchange + - --log-level=INFO + depends_on: + - postgres-keycloak + networks: + - default + deploy: + restart_policy: + condition: on-failure vault: image: ghcr.io/opexdev/vault-opex volumes: @@ -126,6 +153,7 @@ services: - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-hiopex} - OPEX_ADMIN_KEYCLOAK_CLIENT_SECRET=${OPEX_ADMIN_KEYCLOAK_CLIENT_SECRET} - VANDAR_API_KEY=$VANDAR_API_KEY + - API_CRYPTO_KEY=${API_CRYPTO_KEY} cap_add: - IPC_LOCK deploy: @@ -189,6 +217,10 @@ services: <<: *postgres-db volumes: - auth-data:/var/lib/postgresql/data/ + postgres-keycloak: + <<: *postgres-db + volumes: + - keycloak-data:/var/lib/postgresql/data/ postgres-wallet: <<: *postgres-db volumes: @@ -205,6 +237,22 @@ services: <<: *postgres-db volumes: - bc-gateway-data:/var/lib/postgresql/data/ + postgres-matching-gateway: + <<: *postgres-db + volumes: + - matching-gateway-data:/var/lib/postgresql/data/ + postgres-otp: + <<: *postgres-db + volumes: + - otp-data:/var/lib/postgresql/data/ + postgres-profile: + <<: *postgres-db + volumes: + - profile-data:/var/lib/postgresql/data/ + postgres-device-management: + <<: *postgres-db + volumes: + - device-management-data:/var/lib/postgresql/data/ accountant: image: ghcr.io/opexdev/accountant environment: @@ -216,16 +264,17 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - - PREFERENCES=$PREFERENCES - configs: - - preferences.yml + - TRADE_VOLUME_CALCULATION_CURRENCY=${TRADE_VOLUME_CALCULATION_CURRENCY} + - WITHDRAW_VOLUME_CALCULATION_CURRENCY=${WITHDRAW_VOLUME_CALCULATION_CURRENCY} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} networks: - default depends_on: - kafka-1 - kafka-2 - kafka-3 - - wallet - consul - vault - postgres-accountant @@ -264,9 +313,9 @@ services: - KAFKA_IP_PORT=kafka-1:29092,kafka-2:29092,kafka-3:29092 - REDIS_HOST=redis - SYMBOLS=BTC_USDT,ETH_USDT,BTC_IRT,ETH_IRT,USDT_IRT,ETH_BUSD,BTC_BUSD,BNB_BUSD - - PREFERENCES=$PREFERENCES - configs: - - preferences.yml + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} networks: - default depends_on: @@ -286,9 +335,9 @@ services: - KAFKA_IP_PORT=kafka-1:29092,kafka-2:29092,kafka-3:29092 - REDIS_HOST=redis-duo - SYMBOLS=SOL_USDT,DOGE_USDT,TON_USDT - - PREFERENCES=$PREFERENCES - configs: - - preferences.yml + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} networks: - default depends_on: @@ -307,15 +356,22 @@ services: - JAVA_OPTS=-Xmx256m -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 - KAFKA_IP_PORT=kafka-1:29092,kafka-2:29092,kafka-3:29092 - CONSUL_HOST=consul + - DB_IP_PORT=postgres-matching-gateway + - BACKEND_USER=${BACKEND_USER} + - VAULT_HOST=vault + - SYMBOLS=BTC_USDT,ETH_USDT,BTC_IRT,ETH_IRT,USDT_IRT,ETH_BUSD,BTC_BUSD,BNB_BUSD + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} networks: - default depends_on: - kafka-1 - kafka-2 - kafka-3 - - auth - consul - - matching-engine + - postgres-matching-gateway labels: collect_logs: "true" deploy: @@ -335,7 +391,6 @@ services: - FORGOT_REDIRECT_URL=$KEYCLOAK_FORGOT_REDIRECT_URL - VAULT_URL=http://vault:8200 - VAULT_HOST=vault - - PREFERENCES=$PREFERENCES - APP_NAME=$APP_NAME - APP_BASE_URL=$APP_BASE_URL - WHITELIST_REGISTER_ENABLED=$WHITELIST_REGISTER_ENABLED @@ -354,6 +409,36 @@ services: deploy: restart_policy: condition: on-failure + auth-gateway: + image: ghcr.io/opexdev/auth-gateway + environment: + - JAVA_OPTS=-Xmx256m -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + - KAFKA_IP_PORT=kafka-1:29092,kafka-2:29092,kafka-3:29092 + - CONSUL_HOST=consul + - ADMIN_CLIENT_SECRET=${KC_ADMIN_CLIENT_SECRET} + - PRE_AUTH_CLIENT_SECRET=${KC_PRE_AUTH_CLIENT_SECRET} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - CAPTCHA_ENABLED=${CAPTCHA_ENABLED} + - 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} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS} + - OPEN_API_SERVER_URL=${OPEN_API_SERVER_URL_AUTH} + volumes: + - auth-gateway-keys:/app/keys + depends_on: + - keycloak + networks: + - default + labels: + collect_logs: "true" + deploy: + restart_policy: + condition: on-failure wallet: image: ghcr.io/opexdev/wallet environment: @@ -364,16 +449,23 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - - PREFERENCES=$PREFERENCES - DRIVE_FOLDER_ID=$DRIVE_FOLDER_ID - - BACKUP_ENABLED=$WALLET_BACKUP_ENABLED - configs: - - preferences.yml + - STORAGE_FOLDER_ID=$STORAGE_FOLDER_ID + - BACKUP_ENABLED_STORAGE=$WALLET_BACKUP_ENABLED_STORAGE + - BACKUP_ENABLED_GOOGLE_DRIVE=$WALLET_BACKUP_ENABLED_GOOGLE_DRIVE + - SYMBOLS=BTC_USDT,ETH_USDT,BTC_IRT,ETH_IRT,USDT_IRT,ETH_BUSD,BTC_BUSD,BNB_BUSD + - WITHDRAW_LIMIT_ENABLED=${WITHDRAW_LIMIT_ENABLED} + - WITHDRAW_OTP_REQUIRED_COUNT=${WITHDRAW_OTP_REQUIRED_COUNT} + - WITHDRAW_BANK_ACCOUNT_VALIDATION=${WITHDRAW_BANK_ACCOUNT_VALIDATION} + - TOTAL_ASSET_CALCULATION_CURRENCY=${TOTAL_ASSET_CALCULATION_CURRENCY} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} depends_on: - kafka-1 - kafka-2 - kafka-3 - - auth - consul - vault - postgres-wallet @@ -394,12 +486,14 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - - PREFERENCES=$PREFERENCES + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} depends_on: - kafka-1 - kafka-2 - kafka-3 - - auth - consul - vault - postgres-market @@ -420,16 +514,22 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - - PREFERENCES=$PREFERENCES - API_KEY_CLIENT_SECRET=$API_KEY_CLIENT_SECRET - configs: - - preferences.yml + - TRADE_VOLUME_CALCULATION_CURRENCY=${TRADE_VOLUME_CALCULATION_CURRENCY} + - WITHDRAW_VOLUME_CALCULATION_CURRENCY=${WITHDRAW_VOLUME_CALCULATION_CURRENCY} + - TOTAL_ASSET_CALCULATION_CURRENCY=${TOTAL_ASSET_CALCULATION_CURRENCY} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - APP_BASE_URL=${APP_BASE_URL} + - 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} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS} + - OPEN_API_SERVER_URL=${OPEN_API_SERVER_URL_API} depends_on: - - accountant - - matching-gateway - - wallet - - market - - auth - consul - vault - postgres-api @@ -450,16 +550,15 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - - PREFERENCES=$PREFERENCES - ADDRESS_EXP_TIME=100 - configs: - - preferences.yml + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} depends_on: - kafka-1 - kafka-2 - kafka-3 - - auth - - wallet - consul - vault - postgres-bc-gateway @@ -470,6 +569,116 @@ services: deploy: restart_policy: condition: on-failure + otp: + image: ghcr.io/opexdev/otp + environment: + - JAVA_OPTS=-Xmx256m -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + - CONSUL_HOST=consul + - DB_IP_PORT=postgres-otp + - DB_USER=${DB_USER:-opex} + - DB_PASS=${DB_PASS:-hiopex} + - SMS_PROVIDER_URL=${SMS_PROVIDER_URL} + - SMS_PROVIDER_API_KEY=${SMS_PROVIDER_API_KEY} + - SMS_PROVIDER_TEMPLATE=${SMS_PROVIDER_TEMPLATE} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_USER=${SMTP_USER} + - SMTP_PASS=${SMTP_PASS} + - SMTP_FROM=${SMTP_FROM} + - SMTP_PROXY_HOST=${SMTP_PROXY_HOST} + - SMTP_PROXY_PORT=${SMTP_PROXY_PORT} + - SMTP_PROXY_ENABLED=${SMTP_PROXY_ENABLED} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - OTP_CODE_RESPONSE_ENABLED=${OTP_CODE_RESPONSE_ENABLED} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + - DEFAULT_SMS_PROVIDER=${DEFAULT_SMS_PROVIDER} + depends_on: + - consul + - postgres-otp + networks: + - default + labels: + collect_logs: "true" + deploy: + restart_policy: + condition: on-failure + profile: + image: ghcr.io/opexdev/profile + environment: + - JAVA_OPTS=-Xmx256m -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + - CONSUL_HOST=consul + - DB_IP_PORT=postgres-profile + - KAFKA_IP_PORT=kafka-1:29092,kafka-2:29092,kafka-3:29092 + - BACKEND_USER=${BACKEND_USER} + - VAULT_HOST=vault + - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL + - JIBIT_URL=${JIBIT_URL} + - JIBIT_API_KEY=${JIBIT_API_KEY} + - JIBIT_SECRET_KEY=${JIBIT_SECRET_KEY} + - ADMIN_APPROVAL_PROFILE_COMPLETION_REQUEST=${ADMIN_APPROVAL_PROFILE_COMPLETION_REQUEST} + - ADMIN_APPROVAL_BANK_ACCOUNT=${ADMIN_APPROVAL_BANK_ACCOUNT} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} + - MOBILE_IDENTITY_INQUIRY=${MOBILE_IDENTITY_INQUIRY} + - PERSONAL_IDENTITY_INQUIRY=${PERSONAL_IDENTITY_INQUIRY} + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + depends_on: + - kafka-1 + - kafka-2 + - kafka-3 + - consul + - postgres-profile + - vault + labels: + collect_logs: "true" + deploy: + restart_policy: + condition: on-failure + device-management: + image: ghcr.io/opexdev/device-management + environment: + - JAVA_OPTS=-Xmx256m -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + - CONSUL_HOST=consul + - DB_USER=${DB_USER:-opex} + - DB_PASS=${DB_PASS:-hiopex} + - DB_IP_PORT=postgres-device-management + - KAFKA_IP_PORT=kafka-1:29092,kafka-2:29092,kafka-3:29092 + - BACKEND_USER=${BACKEND_USER} + - VAULT_HOST=vault + - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL + - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} + - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} + - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + depends_on: + - kafka-1 + - kafka-2 + - kafka-3 + - consul + - postgres-device-management + - vault + labels: + collect_logs: "true" + networks: + - default + deploy: + restart_policy: + condition: on-failure + swagger-docs: + image: swaggerapi/swagger-ui:latest + 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: @@ -485,10 +694,16 @@ volumes: accountant-data: eventlog-data: auth-data: + keycloak-data: wallet-data: market-data: api-data: bc-gateway-data: + matching-gateway-data: + otp-data: + auth-gateway-keys: + profile-data: + device-management-data: networks: opex: external: true @@ -499,6 +714,3 @@ secrets: file: opex.dev.crt private_pem: file: private.pem -configs: - preferences.yml: - file: preferences.yml diff --git a/docker-images/kafka/Dockerfile b/docker-images/kafka/Dockerfile index e3c2623ba..43f699594 100644 --- a/docker-images/kafka/Dockerfile +++ b/docker-images/kafka/Dockerfile @@ -1,4 +1,4 @@ -FROM confluentinc/cp-kafka:7.1.1 +FROM confluentinc/cp-kafka:8.0.0 USER root RUN mkdir /opt/prometheus RUN chmod +rx /opt/prometheus diff --git a/docker-images/kafka/kafka-jmx-exporter.yml b/docker-images/kafka/kafka-jmx-exporter.yml index 4de5dad60..4ef7bcd6d 100644 --- a/docker-images/kafka/kafka-jmx-exporter.yml +++ b/docker-images/kafka/kafka-jmx-exporter.yml @@ -172,20 +172,20 @@ rules: "$3": "$4" # Quotas - - pattern : 'kafka.server<>(.+):' + - pattern: 'kafka.server<>(.+):' name: kafka_server_$1_$4 type: GAUGE labels: user: "$2" client-id: "$3" - - pattern : 'kafka.server<>(.+):' + - pattern: 'kafka.server<>(.+):' name: kafka_server_$1_$3 type: GAUGE labels: user: "$2" - - pattern : 'kafka.server<>(.+):' + - pattern: 'kafka.server<>(.+):' name: kafka_server_$1_$3 type: GAUGE labels: diff --git a/docker-images/vault/workflow-vault.sh b/docker-images/vault/workflow-vault.sh index 3d11ed140..cbe570f75 100755 --- a/docker-images/vault/workflow-vault.sh +++ b/docker-images/vault/workflow-vault.sh @@ -51,6 +51,7 @@ init_secrets() { vault write auth/app-id/map/app-id/opex-eventlog value=backend-policy display_name=opex-eventlog vault write auth/app-id/map/app-id/opex-auth value=backend-policy display_name=opex-auth vault write auth/app-id/map/app-id/opex-wallet value=backend-policy display_name=opex-wallet + vault write auth/app-id/map/app-id/opex-matching-gateway value=backend-policy display_name=opex-matching-gateway vault write auth/app-id/map/app-id/opex-websocket value=backend-policy display_name=opex-websocket vault write auth/app-id/map/app-id/opex-payment value=backend-policy display_name=opex-payment vault write auth/app-id/map/app-id/opex-admin value=backend-policy display_name=opex-admin @@ -62,11 +63,12 @@ init_secrets() { vault write auth/app-id/map/app-id/opex-referral value=backend-policy display_name=opex-referral vault write auth/app-id/map/app-id/opex-profile value=backend-policy display_name=opex-profile vault write auth/app-id/map/app-id/opex-kyc value=backend-policy display_name=opex-kyc + vault write auth/app-id/map/app-id/opex-device-management value=backend-policy display_name=opex-device-management ## Enable user-id vault write auth/app-id/map/user-id/${BACKEND_USER} \ - value=opex-wallet,opex-websocket,opex-eventlog,opex-auth,opex-accountant,opex-api,opex-market,opex-bc-gateway,opex-payment,opex-admin,bitcoin-scanner,ethereum-scanner,tron-scanner,scanner-scheduler,scanner-liaison,opex-referral,opex-profile,opex-kyc + value=opex-wallet,opex-websocket,opex-eventlog,opex-auth,opex-accountant,opex-matching-gateway,opex-api,opex-market,opex-bc-gateway,opex-payment,opex-admin,bitcoin-scanner,ethereum-scanner,tron-scanner,scanner-scheduler,scanner-liaison,opex-referral,opex-profile,opex-kyc,opex-device-management ## Check login app-id vault write auth/app-id/login/opex-accountant user_id=${BACKEND_USER} @@ -76,6 +78,7 @@ init_secrets() { vault write auth/app-id/login/opex-eventlog user_id=${BACKEND_USER} vault write auth/app-id/login/opex-auth user_id=${BACKEND_USER} vault write auth/app-id/login/opex-wallet user_id=${BACKEND_USER} + vault write auth/app-id/login/opex-matching-gateway user_id=${BACKEND_USER} vault write auth/app-id/login/opex-websocket user_id=${BACKEND_USER} vault write auth/app-id/login/opex-payment user_id=${BACKEND_USER} vault write auth/app-id/login/opex-admin user_id=${BACKEND_USER} @@ -87,16 +90,19 @@ init_secrets() { vault write auth/app-id/login/opex-referral user_id=${BACKEND_USER} vault write auth/app-id/login/opex-profile user_id=${BACKEND_USER} vault write auth/app-id/login/opex-kyc user_id=${BACKEND_USER} + vault write auth/app-id/login/opex-device-management user_id=${BACKEND_USER} + ## Add secret values vault kv put secret/opex smtppass=${SMTP_PASS} vault kv put secret/opex-accountant dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} - vault kv put secret/opex-api dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} + vault kv put secret/opex-api dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} api_crypto_key=${API_CRYPTO_KEY} vault kv put secret/opex-market dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vault kv put secret/opex-bc-gateway dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} client_id=${CLIENT_ID} client_secret=${CLIENT_SECRET} vault kv put secret/opex-eventlog dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vault kv put secret/opex-auth dbusername=${DB_USER} dbpassword=${DB_PASS} admin_username=${KEYCLOAK_ADMIN_USERNAME} admin_password=${KEYCLOAK_ADMIN_PASSWORD} vault kv put secret/opex-wallet dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} client_id=${CLIENT_ID} client_secret=${CLIENT_SECRET} + vault kv put secret/opex-matching-gateway dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} client_id=${CLIENT_ID} client_secret=${CLIENT_SECRET} vault kv put secret/opex-websocket dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vault kv put secret/opex-payment dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vandar_api_key=${VANDAR_API_KEY} vault kv put secret/opex-admin keycloak_client_secret=${OPEX_ADMIN_KEYCLOAK_CLIENT_SECRET} @@ -108,6 +114,7 @@ init_secrets() { vault kv put secret/opex-referral dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vault kv put secret/opex-profile dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vault kv put secret/opex-kyc dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} + vault kv put secret/opex-device-management dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} } diff --git a/eventlog/eventlog-app/Dockerfile b/eventlog/eventlog-app/Dockerfile index 9c566c1b2..29f0c2703 100644 --- a/eventlog/eventlog-app/Dockerfile +++ b/eventlog/eventlog-app/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11 +FROM eclipse-temurin:17-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] diff --git a/eventlog/eventlog-app/pom.xml b/eventlog/eventlog-app/pom.xml index 161a09170..ae93544bc 100644 --- a/eventlog/eventlog-app/pom.xml +++ b/eventlog/eventlog-app/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/eventlog/eventlog-app/src/main/kotlin/co/nilin/opex/eventlog/app/listeners/DeadLetterListener.kt b/eventlog/eventlog-app/src/main/kotlin/co/nilin/opex/eventlog/app/listeners/DeadLetterListener.kt index 0a9ce8f5b..9309af238 100644 --- a/eventlog/eventlog-app/src/main/kotlin/co/nilin/opex/eventlog/app/listeners/DeadLetterListener.kt +++ b/eventlog/eventlog-app/src/main/kotlin/co/nilin/opex/eventlog/app/listeners/DeadLetterListener.kt @@ -19,28 +19,29 @@ class DeadLetterListener(private val persister: DeadLetterPersister) : DLTListen return "EventLogDeadLetterListener" } - override fun onEvent(event: String?, partition: Int, offset: Long, timestamp: Long, headers: Headers) = runBlocking { - logger.info("Dead letter event received: $event") - val map = hashMapOf().apply { - headers.forEach { - put(it.key(), it.value().toString(Charsets.UTF_8)) + override fun onEvent(event: String?, partition: Int, offset: Long, timestamp: Long, headers: Headers) = + runBlocking { + logger.info("Dead letter event received: $event") + val map = hashMapOf().apply { + headers.forEach { + put(it.key(), it.value().toString(Charsets.UTF_8)) + } } - } - val dlt = DeadLetterEvent( - map["dlt-origin-module"]!!, - map[KafkaHeaders.DLT_ORIGINAL_TOPIC], - map[KafkaHeaders.DLT_ORIGINAL_CONSUMER_GROUP], - map[KafkaHeaders.DLT_EXCEPTION_MESSAGE], - map[KafkaHeaders.DLT_EXCEPTION_STACKTRACE], - map[KafkaHeaders.DLT_EXCEPTION_FQCN], - event, - LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), TimeZone.getDefault().toZoneId()) - ) - - persister.save(dlt) - logger.info("DLT persisted") - } + val dlt = DeadLetterEvent( + map["dlt-origin-module"]!!, + map[KafkaHeaders.DLT_ORIGINAL_TOPIC], + map[KafkaHeaders.DLT_ORIGINAL_CONSUMER_GROUP], + map[KafkaHeaders.DLT_EXCEPTION_MESSAGE], + map[KafkaHeaders.DLT_EXCEPTION_STACKTRACE], + map[KafkaHeaders.DLT_EXCEPTION_FQCN], + event, + LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), TimeZone.getDefault().toZoneId()) + ) + + persister.save(dlt) + logger.info("DLT persisted") + } } \ No newline at end of file diff --git a/eventlog/eventlog-app/src/main/resources/application.yml b/eventlog/eventlog-app/src/main/resources/application.yml index 964e5cf10..c7542b976 100644 --- a/eventlog/eventlog-app/src/main/resources/application.yml +++ b/eventlog/eventlog-app/src/main/resources/application.yml @@ -5,7 +5,7 @@ spring: main: allow-circular-references: true kafka: - bootstrap-servers: ${KAFKA_IP_PORT:localhost:9092} + bootstrap-servers: ${KAFKA_IP_PORT:localhost:9092} consumer: group-id: eventlog r2dbc: @@ -34,11 +34,15 @@ management: web: base-path: /actuator exposure: - include: ["health", "prometheus", "metrics"] + include: [ "health", "prometheus", "metrics" ] endpoint: health: show-details: when_authorized metrics: enabled: true prometheus: - enabled: true \ No newline at end of file + enabled: true +app: + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} \ No newline at end of file diff --git a/eventlog/eventlog-core/pom.xml b/eventlog/eventlog-core/pom.xml index 0b8ad9765..ef376ebc5 100644 --- a/eventlog/eventlog-core/pom.xml +++ b/eventlog/eventlog-core/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/pom.xml b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/pom.xml index 9a6857784..6f88e8565 100644 --- a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/pom.xml +++ b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/config/KafkaTopicConfig.kt b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/config/KafkaTopicConfig.kt index 2ac0fab81..0146b4cd2 100644 --- a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/config/KafkaTopicConfig.kt +++ b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/config/KafkaTopicConfig.kt @@ -18,7 +18,7 @@ class KafkaTopicConfig { registerBean("topic_richTrade.DLT", NewTopic::class.java, "richTrade.DLT", 10, 1) registerBean("topic_richOrder.DLT", NewTopic::class.java, "richOrder.DLT", 10, 1) registerBean("topic_admin_event.DLT", NewTopic::class.java, "admin_event.DLT", 10, 1) - registerBean("topic_auth_user_created.DLT", NewTopic::class.java, "auth_user_created.DLT", 10, 1) + registerBean("topic_auth.DLT", NewTopic::class.java, "auth.DLT", 10, 1) } } diff --git a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/consumer/DLTKafkaListener.kt b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/consumer/DLTKafkaListener.kt index aa9ee7a69..6c09d4555 100644 --- a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/consumer/DLTKafkaListener.kt +++ b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/consumer/DLTKafkaListener.kt @@ -12,7 +12,15 @@ class DLTKafkaListener : MessageListener { override fun onMessage(data: ConsumerRecord) { - listeners.forEach { it.onEvent(data.value(), data.partition(), data.offset(), data.timestamp(), data.headers()) } + listeners.forEach { + it.onEvent( + data.value(), + data.partition(), + data.offset(), + data.timestamp(), + data.headers() + ) + } } fun addEventListener(tl: DLTListener) { diff --git a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/inout/OrderRequestEvent.kt b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/inout/OrderRequestEvent.kt index d0d6381cf..d569df4d1 100644 --- a/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/inout/OrderRequestEvent.kt +++ b/eventlog/eventlog-ports/eventlog-eventlistener-kafka/src/main/kotlin/co/nilin/opex/eventlog/ports/kafka/listener/inout/OrderRequestEvent.kt @@ -2,4 +2,4 @@ package co.nilin.opex.eventlog.ports.kafka.listener.inout import co.nilin.opex.matching.engine.core.model.Pair -abstract class OrderRequestEvent(val ouid:String, val uuid: String, val pair: Pair) \ No newline at end of file +abstract class OrderRequestEvent(val ouid: String, val uuid: String, val pair: Pair) \ No newline at end of file diff --git a/eventlog/eventlog-ports/eventlog-persister-postgres/pom.xml b/eventlog/eventlog-ports/eventlog-persister-postgres/pom.xml index 9df95effb..811952e9c 100644 --- a/eventlog/eventlog-ports/eventlog-persister-postgres/pom.xml +++ b/eventlog/eventlog-ports/eventlog-persister-postgres/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/eventlog/eventlog-ports/eventlog-persister-postgres/src/main/resources/schema.sql b/eventlog/eventlog-ports/eventlog-persister-postgres/src/main/resources/schema.sql index f4c381101..3f58a1c7f 100644 --- a/eventlog/eventlog-ports/eventlog-persister-postgres/src/main/resources/schema.sql +++ b/eventlog/eventlog-ports/eventlog-persister-postgres/src/main/resources/schema.sql @@ -1,79 +1,189 @@ CREATE TABLE IF NOT EXISTS opex_orders ( - id SERIAL PRIMARY KEY, - ouid VARCHAR(72) NOT NULL UNIQUE, - symbol VARCHAR(20) NOT NULL, - direction VARCHAR(20) NOT NULL, - match_constraint VARCHAR(20) NOT NULL, - order_type VARCHAR(20) NOT NULL, - uuid VARCHAR(72) NOT NULL, - agent VARCHAR(20), - ip VARCHAR(11), - order_date TIMESTAMP NOT NULL, - create_date TIMESTAMP NOT NULL -); + id + SERIAL + PRIMARY + KEY, + ouid + VARCHAR +( + 72 +) NOT NULL UNIQUE, + symbol VARCHAR +( + 20 +) NOT NULL, + direction VARCHAR +( + 20 +) NOT NULL, + match_constraint VARCHAR +( + 20 +) NOT NULL, + order_type VARCHAR +( + 20 +) NOT NULL, + uuid VARCHAR +( + 72 +) NOT NULL, + agent VARCHAR +( + 20 +), + ip VARCHAR +( + 11 +), + order_date TIMESTAMP NOT NULL, + create_date TIMESTAMP NOT NULL + ); CREATE TABLE IF NOT EXISTS opex_order_events ( - id SERIAL PRIMARY KEY, - ouid VARCHAR(72) NOT NULL, + id + SERIAL + PRIMARY + KEY, + ouid + VARCHAR +( + 72 +) NOT NULL, matching_orderid BIGINT, - price BIGINT, - quantity BIGINT, - filled_quantity BIGINT, - uuid VARCHAR(72) NOT NULL, - event VARCHAR(30) NOT NULL, - agent VARCHAR(20), - ip VARCHAR(11), - event_date TIMESTAMP NOT NULL, - create_date TIMESTAMP NOT NULL -); + price BIGINT, + quantity BIGINT, + filled_quantity BIGINT, + uuid VARCHAR +( + 72 +) NOT NULL, + event VARCHAR +( + 30 +) NOT NULL, + agent VARCHAR +( + 20 +), + ip VARCHAR +( + 11 +), + event_date TIMESTAMP NOT NULL, + create_date TIMESTAMP NOT NULL + ); CREATE TABLE IF NOT EXISTS opex_events ( - id SERIAL PRIMARY KEY, - correlation_id VARCHAR(72) NOT NULL, - ouid VARCHAR(72) NOT NULL, - uuid VARCHAR(72) NOT NULL, - symbol VARCHAR(20) NOT NULL, - event VARCHAR(30) NOT NULL, - event_json TEXT NOT NULL, - agent VARCHAR(20), - ip VARCHAR(11), - event_date TIMESTAMP NOT NULL, - create_date TIMESTAMP NOT NULL -); + id + SERIAL + PRIMARY + KEY, + correlation_id + VARCHAR +( + 72 +) NOT NULL, + ouid VARCHAR +( + 72 +) NOT NULL, + uuid VARCHAR +( + 72 +) NOT NULL, + symbol VARCHAR +( + 20 +) NOT NULL, + event VARCHAR +( + 30 +) NOT NULL, + event_json TEXT NOT NULL, + agent VARCHAR +( + 20 +), + ip VARCHAR +( + 11 +), + event_date TIMESTAMP NOT NULL, + create_date TIMESTAMP NOT NULL + ); CREATE TABLE IF NOT EXISTS opex_trades ( - id SERIAL PRIMARY KEY, - symbol VARCHAR(20) NOT NULL, - taker_ouid VARCHAR(72) NOT NULL, - taker_uuid VARCHAR(72) NOT NULL, - taker_matching_orderid BIGINT NOT NULL, - taker_direction VARCHAR(20) NOT NULL, - taker_price BIGINT NOT NULL, - taker_remained_quantity BIGINT NOT NULL, - maker_ouid VARCHAR(72) NOT NULL, - maker_uuid VARCHAR(72) NOT NULL, - maker_matching_orderid BIGINT NOT NULL, - maker_direction VARCHAR(20) NOT NULL, - maker_price BIGINT NOT NULL, - maker_remained_quantity BIGINT NOT NULL, - matched_quantity BIGINT NOT NULL, - trade_date TIMESTAMP NOT NULL, - create_date TIMESTAMP NOT NULL -); + id + SERIAL + PRIMARY + KEY, + symbol + VARCHAR +( + 20 +) NOT NULL, + taker_ouid VARCHAR +( + 72 +) NOT NULL, + taker_uuid VARCHAR +( + 72 +) NOT NULL, + taker_matching_orderid BIGINT NOT NULL, + taker_direction VARCHAR +( + 20 +) NOT NULL, + taker_price BIGINT NOT NULL, + taker_remained_quantity BIGINT NOT NULL, + maker_ouid VARCHAR +( + 72 +) NOT NULL, + maker_uuid VARCHAR +( + 72 +) NOT NULL, + maker_matching_orderid BIGINT NOT NULL, + maker_direction VARCHAR +( + 20 +) NOT NULL, + maker_price BIGINT NOT NULL, + maker_remained_quantity BIGINT NOT NULL, + matched_quantity BIGINT NOT NULL, + trade_date TIMESTAMP NOT NULL, + create_date TIMESTAMP NOT NULL + ); CREATE TABLE IF NOT EXISTS dead_letter_events ( - id SERIAL PRIMARY KEY, - origin_module VARCHAR(72) NOT NULL, - origin_topic VARCHAR(72), - consumer_group VARCHAR(72), - exception_message TEXT, + id + SERIAL + PRIMARY + KEY, + origin_module + VARCHAR +( + 72 +) NOT NULL, + origin_topic VARCHAR +( + 72 +), + consumer_group VARCHAR +( + 72 +), + exception_message TEXT, exception_stacktrace TEXT, exception_class_name TEXT, - timestamp TIMESTAMP NOT NULL, - value TEXT -) + timestamp TIMESTAMP NOT NULL, + value TEXT + ) diff --git a/eventlog/pom.xml b/eventlog/pom.xml index bc56375fa..0619de2ec 100644 --- a/eventlog/pom.xml +++ b/eventlog/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/market/market-app/Dockerfile b/market/market-app/Dockerfile index 3e24f1ef1..48ca5b56b 100644 --- a/market/market-app/Dockerfile +++ b/market/market-app/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11 +FROM eclipse-temurin:17-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] diff --git a/market/market-app/pom.xml b/market/market-app/pom.xml index 37d7e0e47..071a12447 100644 --- a/market/market-app/pom.xml +++ b/market/market-app/pom.xml @@ -67,6 +67,10 @@ co.nilin.opex.market.ports.kafka.listener market-eventlistener-kafka + + co.nilin.opex.market.ports.kafka.producer + market-eventproducer-kafka + org.springframework.boot spring-boot-starter-actuator @@ -84,15 +88,21 @@ org.springframework.cloud spring-cloud-starter-vault-config - - co.nilin.opex.utility - preferences - io.micrometer micrometer-registry-prometheus runtime + + org.jfree + jfreechart + 1.5.3 + + + org.jfree + jfreesvg + 3.4.3 + diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/MarketAppApplication.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/MarketAppApplication.kt index c8413c59d..f992edac6 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/MarketAppApplication.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/MarketAppApplication.kt @@ -11,5 +11,5 @@ import org.springframework.scheduling.annotation.EnableScheduling class MarketAppApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/InitializeService.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/InitializeService.kt deleted file mode 100644 index edeba1083..000000000 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/InitializeService.kt +++ /dev/null @@ -1,30 +0,0 @@ -package co.nilin.opex.market.app.config - -import co.nilin.opex.market.core.inout.RateSource -import co.nilin.opex.market.ports.postgres.dao.CurrencyRateRepository -import co.nilin.opex.utility.preferences.Preferences -import kotlinx.coroutines.reactor.awaitSingleOrNull -import kotlinx.coroutines.runBlocking -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.DependsOn -import org.springframework.stereotype.Component -import java.math.BigDecimal -import javax.annotation.PostConstruct - -@Component -@DependsOn("postgresConfig") -class InitializeService(private val rateRepository: CurrencyRateRepository) { - - @Autowired - private lateinit var preferences: Preferences - - @PostConstruct - fun init() = runBlocking { - preferences.currencies.forEach { - /*rateRepository.createOrUpdate(it.symbol, it.symbol, RateSource.MARKET, BigDecimal.ONE) - .awaitSingleOrNull() - rateRepository.createOrUpdate(it.symbol, it.symbol, RateSource.EXTERNAL, BigDecimal.ONE) - .awaitSingleOrNull()*/ - } - } -} diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt index e1851cb6a..5ff1278c8 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt @@ -1,9 +1,14 @@ package co.nilin.opex.market.app.config +import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import co.nilin.opex.market.app.utils.AudienceValidator import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean +import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +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.web.server.SecurityWebFilterChain @@ -13,26 +18,46 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { - - http.csrf().disable() - .authorizeExchange() - .pathMatchers("/actuator/**").permitAll() - .anyExchange().permitAll() - .and() - .oauth2ResourceServer() - .jwt() - return http.build() + return http.csrf { it.disable() } + .authorizeExchange() { + it.pathMatchers(HttpMethod.GET, "/v1/admin/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") + .pathMatchers("/actuator/**").permitAll() + .pathMatchers("/v1/user/*/orders/open").permitAll() + .pathMatchers("/v1/user/**").authenticated() + .anyExchange().permitAll() + } + .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } } + .build() } + @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) - .webClient(webClient) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } } diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/WebClientConfig.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/WebClientConfig.kt index 3ac945a3e..1548b9677 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/WebClientConfig.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/WebClientConfig.kt @@ -1,12 +1,10 @@ package co.nilin.opex.market.app.config import org.springframework.cloud.client.ServiceInstance -import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.web.reactive.function.client.WebClient import org.zalando.logbook.Logbook import org.zalando.logbook.netty.LogbookClientHandler diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/AdminController.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/AdminController.kt new file mode 100644 index 000000000..98783f1e2 --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/AdminController.kt @@ -0,0 +1,74 @@ +package co.nilin.opex.market.app.controller + +import co.nilin.opex.market.app.data.AdminOrdersHistoryRequest +import co.nilin.opex.market.app.data.RecentTradesRequest +import co.nilin.opex.market.app.data.AdminTradesHistoryRequest +import co.nilin.opex.market.app.utils.asLocalDateTime +import co.nilin.opex.market.core.inout.OrderData +import co.nilin.opex.market.core.inout.TradeData +import co.nilin.opex.market.core.spi.MarketQueryHandler +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("/v1/admin") +class AdminController(private val marketQueryHandler: MarketQueryHandler) { + + @PostMapping("/recent-trades") + suspend fun getRecentTrades( + @RequestBody request: RecentTradesRequest, + ): List? { + return marketQueryHandler.recentTrades( + request.symbol, + request.makerUuid, + request.takerUuid, + request.fromDate?.asLocalDateTime(), + request.toDate?.asLocalDateTime(), + request.excludeSelfTrade, + request.limit, + request.offset + ) + } + + @PostMapping("/trades/history") + suspend fun searchTradesAdmin( + @RequestBody request: AdminTradesHistoryRequest, + ): List? { + return marketQueryHandler.recentTradesAdmin( + symbol=request.symbol, + baseAsset = request.baseAsset, + quoteAsset = request.quoteAsset, + uuid = request.uuid, + makerUuid = request.makerUuid, + takerUuid = request.takerUuid, + ouid = request.ouid, + makerOuid = request.makerOuid, + takerOuid = request.takerOuid, + fromDate = request.fromDate?.asLocalDateTime(), + toDate = request.toDate?.asLocalDateTime(), + excludeSelfTrade = request.excludeSelfTrade, + ascendingByTime = request.ascendingByTime, + limit = request.limit, + offset = request.offset + ) + } + @PostMapping("/orders/history") + suspend fun searchOrdersAdmin( + @RequestBody request: AdminOrdersHistoryRequest, + ): List? { + return marketQueryHandler.recentOrdersAdmin( + request.uuid, + request.symbol, + request.ouid, + request.startTime?.asLocalDateTime(), + request.endTime?.asLocalDateTime(), + request.orderType, + request.direction, + request.ascendingByTime, + request.limit, + request.offset + ) + } +} \ No newline at end of file diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/ChartController.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/ChartController.kt index 88cd3f2dc..5caa5f30c 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/ChartController.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/ChartController.kt @@ -1,13 +1,31 @@ package co.nilin.opex.market.app.controller +import co.nilin.opex.common.OpexError +import co.nilin.opex.market.app.data.SparkLineDataResponse import co.nilin.opex.market.core.inout.CandleData +import co.nilin.opex.market.core.inout.PriceTime import co.nilin.opex.market.core.spi.MarketQueryHandler +import createLineChart import org.springframework.web.bind.annotation.* +import java.math.BigDecimal + @RestController @RequestMapping("/v1/chart") class ChartController(private val marketQueryHandler: MarketQueryHandler) { + enum class Period(val code: String) { + DAILY("24h"), + WEEKLY("7d"), + MONTHLY("1M"); + + companion object { + fun fromCode(code: String): Period? { + return values().find { it.code == code } + } + } + } + @GetMapping("/{symbol}/candle") suspend fun getCandleDataForSymbol( @PathVariable symbol: String, @@ -19,4 +37,22 @@ class ChartController(private val marketQueryHandler: MarketQueryHandler) { return marketQueryHandler.getCandleInfo(symbol, interval, since, until, limit) } + @GetMapping("/spark-line") + suspend fun getSparkLineForSymbols( + @RequestParam("symbols") symbols: List, + @RequestParam("period") periodCode: String + ): List { + val period = Period.fromCode(periodCode) ?: throw OpexError.BadRequest.exception("Invalid period") + return symbols.mapNotNull { symbol -> + val priceData: List = when (period) { + Period.WEEKLY -> marketQueryHandler.getWeeklyPriceData(symbol) + Period.MONTHLY -> marketQueryHandler.getMonthlyPriceData(symbol) + Period.DAILY -> marketQueryHandler.getDailyPriceData(symbol) + } + if (priceData.all { it.closePrice == BigDecimal.ZERO }) return@mapNotNull null + val isTrendUp = priceData.last().closePrice >= priceData.first().closePrice + val svgData = createLineChart(priceData.map { it.closePrice }, priceData.map { it.closeTime }) + SparkLineDataResponse(symbol, isTrendUp, svgData) + } + } } \ No newline at end of file diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketController.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketController.kt index 529b04b06..a4f121f72 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketController.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketController.kt @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.* class MarketController(private val marketQueryHandler: MarketQueryHandler) { @GetMapping("/ticker") - suspend fun priceChangeSince(@RequestParam interval: Interval): List { + suspend fun priceChangeSince(@RequestParam interval: Interval): List? { return marketQueryHandler.getTradeTickerData(interval) } @@ -28,7 +28,7 @@ class MarketController(private val marketQueryHandler: MarketQueryHandler) { @PathVariable symbol: String, @RequestParam direction: OrderDirection, @RequestParam(required = false) limit: Int = 500 - ): List { + ): List? { return if (direction == OrderDirection.BID) marketQueryHandler.openBidOrders(symbol, limit) else @@ -39,7 +39,7 @@ class MarketController(private val marketQueryHandler: MarketQueryHandler) { suspend fun getRecentTradesForSymbol( @PathVariable symbol: String, @RequestParam(required = false) limit: Int = 500 - ): List { + ): List? { return marketQueryHandler.recentTrades(symbol, limit) } @@ -54,7 +54,7 @@ class MarketController(private val marketQueryHandler: MarketQueryHandler) { } @GetMapping("/best-prices") - suspend fun getOrderBookForSymbol(@RequestParam symbols: List): List { + suspend fun getOrderBookForSymbol(@RequestParam symbols: List): List? { return marketQueryHandler.getBestPriceForSymbols(symbols) } diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketStatsController.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketStatsController.kt index ecd3401f7..8d59c349a 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketStatsController.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/MarketStatsController.kt @@ -3,15 +3,11 @@ package co.nilin.opex.market.app.controller import co.nilin.opex.common.utils.Interval import co.nilin.opex.market.core.inout.PriceStat import co.nilin.opex.market.core.inout.TradeVolumeStat -import co.nilin.opex.market.core.inout.Transaction -import co.nilin.opex.market.core.inout.TxOfTrades import co.nilin.opex.market.core.spi.MarketQueryHandler 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.time.LocalDateTime import java.util.* @RestController @@ -19,12 +15,12 @@ import java.util.* class MarketStatsController(private val marketQueryHandler: MarketQueryHandler) { @GetMapping("/price/most-increased") - suspend fun getMostIncreasedPrices(@RequestParam interval: Interval, @RequestParam limit: Int): List { + suspend fun getMostIncreasedPrices(@RequestParam interval: Interval, @RequestParam limit: Int): List? { return marketQueryHandler.mostIncreasePrice(interval, limit) } @GetMapping("/price/most-decreased") - suspend fun getMostDecreasedPrices(@RequestParam interval: Interval, @RequestParam limit: Int): List { + suspend fun getMostDecreasedPrices(@RequestParam interval: Interval, @RequestParam limit: Int): List? { return marketQueryHandler.mostDecreasePrice(interval, limit) } @@ -37,7 +33,4 @@ class MarketStatsController(private val marketQueryHandler: MarketQueryHandler) suspend fun getMostTrades(@RequestParam interval: Interval): TradeVolumeStat? { return marketQueryHandler.mostTrades(interval) } - - - } \ No newline at end of file diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/RateController.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/RateController.kt deleted file mode 100644 index 3a5c4bf4f..000000000 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/RateController.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.nilin.opex.market.app.controller - -import co.nilin.opex.market.core.inout.CurrencyRate -import co.nilin.opex.market.core.inout.RateSource -import co.nilin.opex.market.core.spi.MarketRateService -import org.springframework.web.bind.annotation.* - -@RestController -@RequestMapping("/v1/rate") -class RateController(private val marketRateService: MarketRateService) { - - @GetMapping("/{source}") - suspend fun getRates(@PathVariable source: RateSource, @RequestParam quote: String): List { - return marketRateService.currencyRate(quote.uppercase(), source) - } - - @GetMapping("/{base}/{source}") - suspend fun getRate( - @PathVariable source: RateSource, - @PathVariable base: String, - @RequestParam quote: String - ): CurrencyRate { - return marketRateService.currencyRate(base.uppercase(), quote.uppercase(), source) - } - -} \ No newline at end of file diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/UserDataController.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/UserDataController.kt index b65fa1503..538713e2d 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/UserDataController.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/controller/UserDataController.kt @@ -1,6 +1,7 @@ package co.nilin.opex.market.app.controller import co.nilin.opex.common.OpexError +import co.nilin.opex.market.app.utils.asLocalDateTime import co.nilin.opex.market.core.inout.* import co.nilin.opex.market.core.spi.UserQueryHandler import org.springframework.security.core.annotation.CurrentSecurityContext @@ -9,46 +10,148 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/v1/user") -class UserDataController(private val userQueryHandler: UserQueryHandler) { +class UserDataController( + private val userQueryHandler: UserQueryHandler +) { - @GetMapping("/{uuid}/order/{ouid}") - suspend fun getOrder(@PathVariable uuid: String, @PathVariable ouid: String): Order { - return userQueryHandler.getOrder(uuid, ouid) ?: throw OpexError.NotFound.exception() + @GetMapping("/order/{ouid}") + suspend fun getOrder( + @PathVariable ouid: String, + @CurrentSecurityContext securityContext: SecurityContext, + ): Order { + return userQueryHandler.getOrder(securityContext.authentication.name, ouid) + ?: throw OpexError.NotFound.exception() } - @PostMapping("/{uuid}/order/query") - suspend fun queryUserOrder(@PathVariable uuid: String, @RequestBody request: QueryOrderRequest): Order { - return userQueryHandler.queryOrder(uuid, request) ?: throw OpexError.NotFound.exception() + @PostMapping("/order/query") + suspend fun queryUserOrder( + @RequestBody request: QueryOrderRequest, + @CurrentSecurityContext securityContext: SecurityContext, + ): Order { + return userQueryHandler.queryOrder(securityContext.authentication.name, request) + ?: throw OpexError.NotFound.exception() } - @GetMapping("/{uuid}/orders/{symbol}/open") + //todo should be authenticated as soon as possible + @GetMapping("/{uuid}/orders/open") suspend fun getUserOpenOrders( - @PathVariable uuid: String, - @PathVariable symbol: String, - @RequestParam limit: Int + @RequestParam limit: Int, @PathVariable uuid: String, ): List { - return userQueryHandler.openOrders(uuid, symbol, limit) + return userQueryHandler.openOrders(uuid, limit) } - @PostMapping("/{uuid}/orders") - suspend fun getUserOrders(@PathVariable uuid: String, @RequestBody request: AllOrderRequest): List { - return userQueryHandler.allOrders(uuid, request) + @GetMapping("/orders/{symbol}/open") + suspend fun getUserOpenOrders( + @PathVariable symbol: String, + @RequestParam limit: Int, + @CurrentSecurityContext securityContext: SecurityContext, + ): List { + return userQueryHandler.openOrders(securityContext.authentication.name, symbol, limit) } - @PostMapping("/{uuid}/trades") - suspend fun getUserTrades(@PathVariable uuid: String, @RequestBody request: TradeRequest): List { - return userQueryHandler.allTrades(uuid, request) + @PostMapping("/orders") + suspend fun getUserOrders( + @RequestBody request: AllOrderRequest, + @CurrentSecurityContext securityContext: SecurityContext, + ): List { + return userQueryHandler.allOrders(securityContext.authentication.name, request) } - @PostMapping("/tx/{user}/history") + @PostMapping("/trades") + suspend fun getUserTrades( + @RequestBody request: TradeRequest, + @CurrentSecurityContext securityContext: SecurityContext, + ): List? { + return userQueryHandler.allTrades(securityContext.authentication.name, request) + } + + @PostMapping("/tx/history") suspend fun getTxOfTrades( - @PathVariable user: String, @RequestBody transactionRequest: TransactionRequest, - @CurrentSecurityContext securityContext: SecurityContext + @CurrentSecurityContext securityContext: SecurityContext, ): TransactionResponse? { - if (securityContext.authentication.name != user) - throw OpexError.Forbidden.exception() - return userQueryHandler.txOfTrades(transactionRequest.apply { owner = user }) + return userQueryHandler.txOfTrades(transactionRequest.apply { owner = securityContext.authentication.name }) + } + + @GetMapping("/order/history") + suspend fun getOrderHistory( + @RequestParam symbol: String?, + @RequestParam startTime: Long?, + @RequestParam endTime: Long?, + @RequestParam orderType: MatchingOrderType?, + @RequestParam direction: OrderDirection?, + @RequestParam limit: Int?, + @RequestParam offset: Int?, + @CurrentSecurityContext securityContext: SecurityContext, + ): List { + return userQueryHandler.getOrderHistory( + securityContext.authentication.name, + symbol, + startTime?.let { startTime.asLocalDateTime() }, + endTime?.let { endTime.asLocalDateTime() }, + orderType, + direction, + limit, + offset + ) + } + + @GetMapping("/order/history/count") + suspend fun getOrderHistoryCount( + @RequestParam symbol: String?, + @RequestParam startTime: Long?, + @RequestParam endTime: Long?, + @RequestParam orderType: MatchingOrderType?, + @RequestParam direction: OrderDirection?, + @CurrentSecurityContext securityContext: SecurityContext, + ): Long { + return userQueryHandler.getOrderHistoryCount( + securityContext.authentication.name, + symbol, + startTime?.let { startTime.asLocalDateTime() }, + endTime?.let { endTime.asLocalDateTime() }, + orderType, + direction, + ) + } + + @GetMapping("/trade/history") + suspend fun getTradeHistory( + @RequestParam symbol: String?, + @RequestParam startTime: Long?, + @RequestParam endTime: Long?, + @RequestParam direction: OrderDirection?, + @RequestParam limit: Int?, + @RequestParam offset: Int?, + @CurrentSecurityContext securityContext: SecurityContext, + ): List? { + return userQueryHandler.getTradeHistory( + securityContext.authentication.name, + symbol, + startTime?.let { startTime.asLocalDateTime() }, + endTime?.let { endTime.asLocalDateTime() }, + direction, + limit, + offset + ) + } + + @GetMapping("/trade/history/count") + suspend fun getTradeHistoryCount( + @RequestParam symbol: String?, + @RequestParam startTime: Long?, + @RequestParam endTime: Long?, + @RequestParam direction: OrderDirection?, + @CurrentSecurityContext securityContext: SecurityContext, + ): Long { + + return userQueryHandler.getTradeHistoryCount( + securityContext.authentication.name, + symbol, + startTime?.let { startTime.asLocalDateTime() }, + endTime?.let { endTime.asLocalDateTime() }, + direction, + ) } } \ No newline at end of file diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/AdminOrdersHistoryRequest.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/AdminOrdersHistoryRequest.kt new file mode 100644 index 000000000..33c5e3c5d --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/AdminOrdersHistoryRequest.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.market.app.data + +import co.nilin.opex.market.core.inout.MatchingOrderType +import co.nilin.opex.market.core.inout.OrderDirection + +data class AdminOrdersHistoryRequest( + val uuid: String?, + val symbol: String?, + val ouid: String?, + val startTime: Long?, + val endTime: Long?, + val orderType: MatchingOrderType?, + val direction: OrderDirection?, + val limit: Int? = 100, + val offset: Int? = 0, + val ascendingByTime: Boolean = false, + ) diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/AdminTradesHistoryRequest.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/AdminTradesHistoryRequest.kt new file mode 100644 index 000000000..58d97d6ed --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/AdminTradesHistoryRequest.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.market.app.data + +import org.apache.kafka.common.protocol.types.Field + +data class AdminTradesHistoryRequest( + val symbol: String?, + val baseAsset: String?, + val quoteAsset: String?, + val uuid: String?, + val makerUuid: String?, + val takerUuid: String?, + val ouid: String?, + val makerOuid: String?, + val takerOuid: String?, + val fromDate: Long?, + val toDate: Long?, + val excludeSelfTrade: Boolean = true, + val ascendingByTime: Boolean = false, + val limit: Int? = 100, + val offset: Int? = 0, +) diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/RecentTradesRequest.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/RecentTradesRequest.kt new file mode 100644 index 000000000..7922d4fde --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/RecentTradesRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.market.app.data + +data class RecentTradesRequest( + val symbol: String?, + val makerUuid: String?, + val takerUuid: String?, + val fromDate: Long?, + val toDate: Long?, + val excludeSelfTrade : Boolean = true, + val limit: Int, + val offset: Int, + + ) \ No newline at end of file diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/SparkLineDataResponse.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/SparkLineDataResponse.kt new file mode 100644 index 000000000..3a54ad1b5 --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/data/SparkLineDataResponse.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.market.app.data + +data class SparkLineDataResponse( + val symbol: String, + val isTrendUp: Boolean, + val svgData: String +) diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/service/ReportingService.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/service/ReportingService.kt index 38c05cbf0..af95b163f 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/service/ReportingService.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/service/ReportingService.kt @@ -1,13 +1,11 @@ package co.nilin.opex.market.app.service import co.nilin.opex.common.utils.Interval -import co.nilin.opex.market.app.utils.asLocalDateTime import co.nilin.opex.market.core.spi.MarketQueryHandler import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service -import java.time.LocalDateTime @Service class ReportingService(private val marketQueryHandler: MarketQueryHandler) { @@ -18,9 +16,9 @@ class ReportingService(private val marketQueryHandler: MarketQueryHandler) { runBlocking { try { val count = marketQueryHandler.numberOfOrders(Interval.FifteenMinutes, null) - logger.info("in the last 30 minutes, the number of orders : $count") + logger.info("In the last 30 minutes, the number of orders : $count") } catch (e: Exception) { - logger.error("Could not report orders cont", e) + logger.error("Could not report orders count", e) } } } diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/AudienceValidator.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..1de6b907c --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.market.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/ChartBuilder.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/ChartBuilder.kt new file mode 100644 index 000000000..6621d2af0 --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/ChartBuilder.kt @@ -0,0 +1,73 @@ +import org.jfree.chart.ChartFactory +import org.jfree.chart.JFreeChart +import org.jfree.chart.plot.PlotOrientation +import org.jfree.chart.plot.XYPlot +import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer +import org.jfree.data.xy.XYSeries +import org.jfree.data.xy.XYSeriesCollection +import org.jfree.graphics2d.svg.SVGGraphics2D +import java.awt.Color +import java.awt.Rectangle +import java.math.BigDecimal +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Base64 + +fun createLineChart(prices: List, times: List): String { + val series = XYSeries("Price").apply { + prices.zip(times).forEach { (price, time) -> + add(time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), price.toDouble()) + } + } + val dataset = XYSeriesCollection().apply { + addSeries(series) + } + val chart = ChartFactory.createXYLineChart( + null, // Chart title + null, // X-axis label + null, // Y-axis label + dataset, // Data + PlotOrientation.VERTICAL, + false, + false, + false + ) + val plot: XYPlot = chart.xyPlot + val renderer = XYLineAndShapeRenderer(true, false) + // Set chart color + renderer.setSeriesPaint(0, Color.WHITE) + // Set axis ranges to keep the chart logical + plot.rangeAxis.range = org.jfree.data.Range( + prices.minOrNull()?.toDouble()?.times(0.99) ?: 0.0, + prices.maxOrNull()?.toDouble() ?: 0.0 + ) + + val timesInMillis = times.map { it.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() } + plot.domainAxis.range = org.jfree.data.Range( + timesInMillis.minOrNull()?.toDouble() ?: 0.0, + timesInMillis.maxOrNull()?.toDouble() ?: 0.0 + ) + plot.domainAxis.lowerMargin = 0.0 + plot.domainAxis.upperMargin = 0.0 + + // Remove gridlines, axis, and background + plot.renderer = renderer + plot.isDomainGridlinesVisible = false + plot.isRangeGridlinesVisible = false + plot.backgroundPaint = null + plot.domainAxis.isVisible = false + plot.rangeAxis.isVisible = false + plot.isOutlineVisible = false + chart.backgroundPaint = null + + return chartToSvgString(chart, 100, 35) +} + +fun chartToSvgString(chart: JFreeChart, width: Int, height: Int): String { + val svg = SVGGraphics2D(width, height) + chart.draw(svg, Rectangle(width, height)) + val svgString = svg.svgElement + svg.dispose() + val base64SvgString = Base64.getEncoder().encodeToString(svgString.toByteArray(Charsets.UTF_8)) + return base64SvgString +} diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/PrometheusHealthExtension.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/PrometheusHealthExtension.kt index 90fe2b630..ab1ca9aa0 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/PrometheusHealthExtension.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/PrometheusHealthExtension.kt @@ -6,7 +6,6 @@ import org.springframework.boot.actuate.health.HealthComponent import org.springframework.boot.actuate.health.HealthEndpoint import org.springframework.boot.actuate.health.SystemHealth import org.springframework.context.annotation.Profile -import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component diff --git a/market/market-app/src/main/resources/application.yml b/market/market-app/src/main/resources/application.yml index 7da42c161..1a8bda75e 100644 --- a/market/market-app/src/main/resources/application.yml +++ b/market/market-app/src/main/resources/application.yml @@ -91,6 +91,12 @@ logging: co.nilin: INFO app: auth: - cert-url: lb://opex-auth/auth/realms/opex/protocol/openid-connect/certs + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} swagger: authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token} diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/MarketOrderEvent.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/MarketOrderEvent.kt new file mode 100644 index 000000000..6dd1bd4ef --- /dev/null +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/MarketOrderEvent.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.market.core.inout + +import java.time.LocalDateTime + +open class MarketOrderEvent { + + val time: LocalDateTime = LocalDateTime.now() +} \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/OrderData.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/OrderData.kt new file mode 100644 index 000000000..342296680 --- /dev/null +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/OrderData.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.market.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class OrderData( + val symbol: String, + val ouid: String, + val orderType: MatchingOrderType?, + val side: OrderDirection, + val price: BigDecimal, + val quantity: BigDecimal, + val quoteQuantity: BigDecimal?, + val executedQuantity: BigDecimal?, + val takerFee: BigDecimal?, + val makerFee: BigDecimal?, + val statusCode: Int?, + val status: OrderStatus?, + val appearance: Int?, + val createDate: LocalDateTime, + val updateDate: LocalDateTime, + val uuid: String? +) + diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/PriceTime.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/PriceTime.kt new file mode 100644 index 000000000..8df9109fc --- /dev/null +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/PriceTime.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.market.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class PriceTime( + val closeTime: LocalDateTime, + val closePrice: BigDecimal, +) diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Trade.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Trade.kt index 603284947..7a899cefb 100644 --- a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Trade.kt +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Trade.kt @@ -1,18 +1,19 @@ package co.nilin.opex.market.core.inout import java.math.BigDecimal -import java.util.* +import java.time.LocalDateTime +// User trade data data class Trade( val symbol: String, val id: Long, - val orderId: Long, + val ouid: String? = null, val price: BigDecimal, val quantity: BigDecimal, val quoteQuantity: BigDecimal, val commission: BigDecimal, val commissionAsset: String, - val time: Date, + val time: LocalDateTime, val isBuyer: Boolean, val isMaker: Boolean, val isBestMatch: Boolean, diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TradeData.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TradeData.kt new file mode 100644 index 000000000..c170d9f6c --- /dev/null +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TradeData.kt @@ -0,0 +1,25 @@ +package co.nilin.opex.market.core.inout + +import java.math.BigDecimal +import java.time.LocalDateTime + +// Trade data for admin +data class TradeData( + val tradeId: Long, + val symbol: String, + val baseAsset: String?, + val quoteAsset: String?, + val matchedPrice: BigDecimal, + val matchedQuantity: BigDecimal, + val takerPrice: BigDecimal, + val makerPrice: BigDecimal, + val tradeDate: LocalDateTime, + val makerUuid: String, + val takerUuid: String, + val makerOuid: String?, + val takerOuid: String?, + val makerCommission: BigDecimal?, + val takerCommission: BigDecimal?, + val makerCommissionAsset: String?, + val takerCommissionAsset: String?, +) diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Transaction.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Transaction.kt index 39b85d1b9..72f715fb0 100644 --- a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Transaction.kt +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/Transaction.kt @@ -4,14 +4,14 @@ import java.math.BigDecimal import java.time.LocalDateTime data class Transaction( - var createDate: LocalDateTime, - var volume: BigDecimal, - val transactionPrice: BigDecimal, - var matchedPrice: BigDecimal, - var side: String, - var symbol: String, - var fee: BigDecimal, - var user: String?=null + var createDate: LocalDateTime, + var volume: BigDecimal, + val transactionPrice: BigDecimal, + var matchedPrice: BigDecimal, + var side: String, + var symbol: String, + var fee: BigDecimal, + var user: String? = null ) -data class TxOfTrades(var transactions:List?) \ No newline at end of file +data class TxOfTrades(var transactions: List?) \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionRequest.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionRequest.kt index d9f66d6a4..2dd1ec454 100644 --- a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionRequest.kt +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionRequest.kt @@ -1,12 +1,12 @@ package co.nilin.opex.market.core.inout data class TransactionRequest( - val coin: String?, - val category: String?, - val startTime: Long? = null, - val endTime: Long? = null, - val limit: Int? = 10, - val offset: Int? = 0, - val ascendingByTime: Boolean? = false, - var owner: String? = null + val coin: String?, + val category: String?, + val startTime: Long? = null, + val endTime: Long? = null, + val limit: Int? = 10, + val offset: Int? = 0, + val ascendingByTime: Boolean? = false, + var owner: String? = null ) \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionResponse.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionResponse.kt index bc50f7f80..b78e4eec0 100644 --- a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionResponse.kt +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/TransactionResponse.kt @@ -1,19 +1,18 @@ package co.nilin.opex.market.core.inout import java.math.BigDecimal -import java.time.LocalDateTime -import java.util.Date +import java.util.* data class TransactionDto( - var createDate: Date, - var volume: BigDecimal, - val transactionPrice: BigDecimal, - var matchedPrice: BigDecimal, - var side: String, - var symbol: String, - var fee: BigDecimal, - var user: String?=null + var createDate: Date, + var volume: BigDecimal, + val transactionPrice: BigDecimal, + var matchedPrice: BigDecimal, + var side: String, + var symbol: String, + var fee: BigDecimal, + var user: String? = null ) -data class TransactionResponse(var transactions:List?) \ No newline at end of file +data class TransactionResponse(var transactions: List?) \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/UserTotalVolumeValue.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/UserTotalVolumeValue.kt new file mode 100644 index 000000000..59b438dbb --- /dev/null +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/inout/UserTotalVolumeValue.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.market.core.inout + +import java.math.BigDecimal + +data class UserTotalVolumeValue( + val valueUSDT: BigDecimal, + val valueIRT: BigDecimal +) { + companion object { + fun zero() = UserTotalVolumeValue(BigDecimal.ZERO, BigDecimal.ZERO) + } +} \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketOrderProducer.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketOrderProducer.kt new file mode 100644 index 000000000..cdd805bb5 --- /dev/null +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketOrderProducer.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.market.core.spi + +interface MarketOrderProducer { + + suspend fun openOrderUpdate(uuid: String, pair: String) +} \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketQueryHandler.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketQueryHandler.kt index 37e644c6c..714e13cf4 100644 --- a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketQueryHandler.kt +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketQueryHandler.kt @@ -6,28 +6,70 @@ import java.time.LocalDateTime interface MarketQueryHandler { - suspend fun getTradeTickerData(interval: Interval): List + suspend fun getTradeTickerData(interval: Interval): List? suspend fun getTradeTickerDateBySymbol(symbol: String, interval: Interval): PriceChange? - suspend fun openBidOrders(symbol: String, limit: Int): List + suspend fun openBidOrders(symbol: String, limit: Int): List? - suspend fun openAskOrders(symbol: String, limit: Int): List + suspend fun openAskOrders(symbol: String, limit: Int): List? suspend fun lastOrder(symbol: String): Order? - suspend fun recentTrades(symbol: String, limit: Int): List + suspend fun recentTrades(symbol: String, limit: Int): List? + + suspend fun recentTrades( + symbol: String?, + makerUuid: String?, + takerUuid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + excludeSelfTrade: Boolean, + limit: Int, + offset: Int, + ): List? + + suspend fun recentTradesAdmin( + symbol: String?, + baseAsset: String?, + quoteAsset: String?, + uuid: String?, + makerUuid: String?, + takerUuid: String?, + ouid: String?, + makerOuid: String?, + takerOuid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + excludeSelfTrade: Boolean, + ascendingByTime: Boolean, + limit: Int?, + offset: Int?, + ): List? + + suspend fun recentOrdersAdmin( + uuid: String?, + symbol: String?, + ouid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + ascendingByTime: Boolean, + limit: Int?, + offset: Int?, + ): List? suspend fun lastPrice(symbol: String?): List - suspend fun getBestPriceForSymbols(symbols: List): List + suspend fun getBestPriceForSymbols(symbols: List): List? suspend fun getCandleInfo( - symbol: String, - interval: String, - startTime: Long?, - endTime: Long?, - limit: Int + symbol: String, + interval: String, + startTime: Long?, + endTime: Long?, + limit: Int, ): List suspend fun numberOfActiveUsers(interval: Interval): Long @@ -36,13 +78,17 @@ interface MarketQueryHandler { suspend fun numberOfOrders(interval: Interval, pair: String? = null): Long - suspend fun mostIncreasePrice(interval: Interval, limit: Int): List + suspend fun mostIncreasePrice(interval: Interval, limit: Int): List? - suspend fun mostDecreasePrice(interval: Interval, limit: Int): List + suspend fun mostDecreasePrice(interval: Interval, limit: Int): List? suspend fun mostVolume(interval: Interval): TradeVolumeStat? suspend fun mostTrades(interval: Interval): TradeVolumeStat? + suspend fun getWeeklyPriceData(symbol: String): List + suspend fun getMonthlyPriceData(symbol: String): List + + suspend fun getDailyPriceData(symbol: String): List } \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketRateService.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketRateService.kt deleted file mode 100644 index 230b7cc4b..000000000 --- a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/MarketRateService.kt +++ /dev/null @@ -1,12 +0,0 @@ -package co.nilin.opex.market.core.spi - -import co.nilin.opex.market.core.inout.CurrencyRate -import co.nilin.opex.market.core.inout.RateSource - -interface MarketRateService { - - suspend fun currencyRate(quote: String, source: RateSource): List - - suspend fun currencyRate(base: String, quote: String, source: RateSource): CurrencyRate - -} \ No newline at end of file diff --git a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/UserQueryHandler.kt b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/UserQueryHandler.kt index 1a7d097d9..ef05ce908 100644 --- a/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/UserQueryHandler.kt +++ b/market/market-core/src/main/kotlin/co/nilin/opex/market/core/spi/UserQueryHandler.kt @@ -9,12 +9,51 @@ interface UserQueryHandler { suspend fun queryOrder(uuid: String, request: QueryOrderRequest): Order? + suspend fun openOrders(uuid: String, limit: Int): List + suspend fun openOrders(uuid: String, symbol: String?, limit: Int): List suspend fun allOrders(uuid: String, allOrderRequest: AllOrderRequest): List - suspend fun allTrades(uuid: String, request: TradeRequest): List + suspend fun allTrades(uuid: String, request: TradeRequest): List? suspend fun txOfTrades(transactionRequest: TransactionRequest): TransactionResponse? + suspend fun getOrderHistory( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List + + suspend fun getOrderHistoryCount( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + ): Long + + suspend fun getTradeHistory( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List? + + suspend fun getTradeHistoryCount( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + direction: OrderDirection?, + ): Long } \ No newline at end of file diff --git a/market/market-ports/market-eventproducer-kafka/pom.xml b/market/market-ports/market-eventproducer-kafka/pom.xml new file mode 100644 index 000000000..84c9ccd06 --- /dev/null +++ b/market/market-ports/market-eventproducer-kafka/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + co.nilin.opex.market + market + 1.0.1-beta.7 + ../../pom.xml + + + co.nilin.opex.market.ports.kafka.producer + market-eventproducer-kafka + market-eventproducer-kafka + Market kafka producer of Opex + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-webflux + + + co.nilin.opex.market.core + market-core + + + org.springframework.kafka + spring-kafka + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + org.springframework.kafka + spring-kafka-test + test + + + diff --git a/market/market-ports/market-eventlistener-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/listener/config/KafkaProducerConfig.kt b/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/config/KafkaProducerConfig.kt similarity index 57% rename from market/market-ports/market-eventlistener-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/listener/config/KafkaProducerConfig.kt rename to market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/config/KafkaProducerConfig.kt index e249bbe56..7a667b7a7 100644 --- a/market/market-ports/market-eventlistener-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/listener/config/KafkaProducerConfig.kt +++ b/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/config/KafkaProducerConfig.kt @@ -1,23 +1,33 @@ -package co.nilin.opex.market.ports.kafka.listener.config +package co.nilin.opex.market.ports.kafka.producer.config import co.nilin.opex.market.core.event.RichOrderEvent import co.nilin.opex.market.core.event.RichTrade +import co.nilin.opex.market.core.inout.MarketOrderEvent +import org.apache.kafka.clients.admin.NewTopic import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Autowired 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.support.GenericApplicationContext +import org.springframework.kafka.config.TopicBuilder import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory import org.springframework.kafka.support.serializer.JsonSerializer +import java.util.function.Supplier -@Configuration -class KafkaProducerConfig { +object KafkaTopics { + const val MARKET_ORDER = "marketOrder" +} +@Configuration +class KafkaProducerConfig( @Value("\${spring.kafka.bootstrap-servers}") - private lateinit var bootstrapServers: String + private val bootstrapServers: String +) { @Bean("marketProducerConfigs") fun producerConfigs(): Map { @@ -25,7 +35,8 @@ class KafkaProducerConfig { ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, - ProducerConfig.ACKS_CONFIG to "all" + ProducerConfig.ACKS_CONFIG to "all", + JsonSerializer.TYPE_MAPPINGS to "openOrderUpdateEvent:co.nilin.opex.market.ports.kafka.producer.events.OpenOrderUpdateEvent" ) } @@ -49,4 +60,23 @@ class KafkaProducerConfig { return KafkaTemplate(factory) } + @Bean("marketOrderKafkaTemplate") + fun marketOrderProducerFactory(@Qualifier("marketProducerConfigs") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean + fun kafkaTemplate(@Qualifier("marketOrderKafkaTemplate") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + + @Autowired + fun createUserCreatedTopics(applicationContext: GenericApplicationContext) { + applicationContext.registerBean("topic_marketOrder", NewTopic::class.java, Supplier { + TopicBuilder.name(KafkaTopics.MARKET_ORDER) + .partitions(1) + .replicas(1) + .build() + }) + } } \ No newline at end of file diff --git a/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/events/OpenOrderUpdateEvent.kt b/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/events/OpenOrderUpdateEvent.kt new file mode 100644 index 000000000..1f8557293 --- /dev/null +++ b/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/events/OpenOrderUpdateEvent.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.market.ports.kafka.producer.events + +import co.nilin.opex.market.core.inout.MarketOrderEvent + +data class OpenOrderUpdateEvent(val uuid: String, val pair: String) : MarketOrderEvent() \ No newline at end of file diff --git a/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/producer/MarketOrderProducer.kt b/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/producer/MarketOrderProducer.kt new file mode 100644 index 000000000..e8e322627 --- /dev/null +++ b/market/market-ports/market-eventproducer-kafka/src/main/kotlin/co/nilin/opex/market/ports/kafka/producer/producer/MarketOrderProducer.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.market.ports.kafka.producer.producer + +import co.nilin.opex.common.utils.LoggerDelegate +import co.nilin.opex.market.ports.kafka.producer.config.KafkaTopics +import co.nilin.opex.market.core.inout.MarketOrderEvent +import co.nilin.opex.market.core.spi.MarketOrderProducer +import co.nilin.opex.market.ports.kafka.producer.events.OpenOrderUpdateEvent +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.retry.support.RetryTemplate +import org.springframework.stereotype.Component + +@Component +class MarketOrderProducer(private val template: KafkaTemplate) : MarketOrderProducer { + + private val logger by LoggerDelegate() + + private val retryTemplate = RetryTemplate.builder() + .maxAttempts(10) + .exponentialBackoff(1000, 1.8, 5 * 60 * 1000) + .retryOn(Exception::class.java) + .build() + + private suspend fun send(event: MarketOrderEvent) { + retryTemplate.execute { + template.send(KafkaTopics.MARKET_ORDER, event).addCallback( + { logger.info("Market order event sent") }, + { error -> logger.error("Error sending market order event", error) } + ) + } + } + + override suspend fun openOrderUpdate(uuid: String, pair: String) { + send(OpenOrderUpdateEvent(uuid, pair)) + } +} \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/CurrencyRateRepository.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/CurrencyRateRepository.kt deleted file mode 100644 index 3e40642d2..000000000 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/CurrencyRateRepository.kt +++ /dev/null @@ -1,31 +0,0 @@ -package co.nilin.opex.market.ports.postgres.dao - -import co.nilin.opex.market.core.inout.RateSource -import co.nilin.opex.market.ports.postgres.model.CurrencyRateModel -import org.springframework.data.r2dbc.repository.Query -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import java.math.BigDecimal - -@Repository -interface CurrencyRateRepository : ReactiveCrudRepository { - - @Query( - """ - insert into currency_rate (base, quote, source, rate) - values (:base, :quote, :source, :rate) - on conflict (base, quote, source) - do update set rate = excluded.rate - """ - ) - fun createOrUpdate(base: String, quote: String, source: RateSource, rate: BigDecimal): Mono - - @Query("select * from currency_rate where base = :base and quote = :quote and source = :source") - fun findByBaseAndQuoteAndSource(base: String, quote: String, source: RateSource): Mono - - @Query("select * from currency_rate where quote = :quote and source = :source") - fun findAllByQuoteAndSource(quote: String, source: RateSource): Flux - -} \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/OrderRepository.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/OrderRepository.kt index 164984152..6f7bbd9a3 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/OrderRepository.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/OrderRepository.kt @@ -1,6 +1,8 @@ package co.nilin.opex.market.ports.postgres.dao import co.nilin.opex.market.core.inout.AggregatedOrderPriceModel +import co.nilin.opex.market.core.inout.MatchingOrderType +import co.nilin.opex.market.core.inout.OrderData import co.nilin.opex.market.core.inout.OrderDirection import co.nilin.opex.market.ports.postgres.model.OrderModel import kotlinx.coroutines.flow.Flow @@ -10,6 +12,7 @@ import org.springframework.data.repository.reactive.ReactiveCrudRepository import org.springframework.stereotype.Repository import reactor.core.publisher.Flux import reactor.core.publisher.Mono + import java.time.LocalDateTime import java.util.* @@ -25,8 +28,9 @@ interface OrderRepository : ReactiveCrudRepository { @Query("select * from orders where symbol = :symbol and order_id = :orderId") fun findBySymbolAndOrderId( @Param("symbol") - symbol: String, @Param("orderId") - orderId: Long + symbol: String, + @Param("orderId") + orderId: Long, ): Mono @Query("select * from orders where symbol = :symbol and client_order_id = :origClientOrderId") @@ -34,7 +38,7 @@ interface OrderRepository : ReactiveCrudRepository { @Param("symbol") symbol: String, @Param("origClientOrderId") - origClientOrderId: String + origClientOrderId: String, ): Mono @Query( @@ -53,7 +57,7 @@ interface OrderRepository : ReactiveCrudRepository { symbol: String?, @Param("statuses") status: Collection, - limit: Int + limit: Int, ): Flow @Query( @@ -75,7 +79,7 @@ interface OrderRepository : ReactiveCrudRepository { startTime: Date?, @Param("endTime") endTime: Date?, - limit: Int + limit: Int, ): Flow @Query( @@ -96,7 +100,7 @@ interface OrderRepository : ReactiveCrudRepository { @Param("limit") limit: Int, @Param("statuses") - status: Collection + status: Collection, ): Flux @Query( @@ -117,7 +121,7 @@ interface OrderRepository : ReactiveCrudRepository { @Param("limit") limit: Int, @Param("statuses") - status: Collection + status: Collection, ): Flux @Query("select * from orders where symbol = :symbol order by create_date desc limit 1") @@ -131,4 +135,141 @@ interface OrderRepository : ReactiveCrudRepository { @Query("select count(*) from orders where symbol = :symbol and create_date >= :interval") fun countBySymbolNewerThan(interval: LocalDateTime, symbol: String): Flow + + @Query( + """ +select o.symbol, + o.ouid, + o.order_type, + o.side, + o.price, + o.quantity, + o.quote_quantity, + os.executed_quantity, + o.taker_fee, + o.maker_fee, + os.status as status_code, + os.appearance, + o.create_date, + os.date as update_date, + o.uuid +from orders o + left join (select * + from order_status os1 + where os1.date = (select max(os2.date) + from order_status os2 + where os2.ouid = os1.ouid)) os on o.ouid = os.ouid + WHERE (:uuid is null or o.uuid = :uuid) + and (:symbol is null or o.symbol = :symbol) + and (:startTime is null or o.create_date >= :startTime) + and (:endTime is null or o.create_date <= :endTime) + and (:orderType is null or o.order_type = :orderType) + and (:direction is null or o.side = :direction) +order by create_date desc + limit :limit offset :offset; + """ + ) + fun findByCriteria( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): Flow + + @Query( + """ +select count(*) +from orders o + WHERE (:uuid is null or o.uuid = :uuid) + and (:symbol is null or o.symbol = :symbol) + and (:startTime is null or o.create_date >= :startTime) + and (:endTime is null or o.create_date <= :endTime) + and (:orderType is null or o.order_type = :orderType) + and (:direction is null or o.side = :direction) + """ + ) + fun countByCriteria( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + ): Mono + + @Query(""" +SELECT + o.symbol , + o.ouid , + o.order_type , + o.side , + + o.price AS price, + o.quantity AS quantity, + o.quote_quantity , + + os.executed_quantity, + + o.taker_fee, + o.maker_fee, + + os.status as status_code, + os.appearance, + + o.create_date , + o.update_date, + + o.uuid + +FROM orders o + +LEFT JOIN ( + SELECT DISTINCT ON (ouid) + ouid, + executed_quantity, + status, + appearance, + date + FROM order_status + ORDER BY ouid, date DESC +) os +ON os.ouid = o.ouid + +WHERE + (:uuid IS NULL OR o.uuid = :uuid) + AND (:symbol IS NULL OR o.symbol = :symbol) + AND (:ouid IS NULL OR o.ouid = :ouid) + AND (:fromDate IS NULL OR o.create_date >= :fromDate) + AND (:toDate IS NULL OR o.create_date <= :toDate) + AND (:orderType IS NULL OR o.order_type = :orderType) + AND (:direction IS NULL OR o.side = :direction) + +ORDER BY +CASE WHEN :ascendingByTime = true + THEN o.create_date +END ASC, + +CASE WHEN :ascendingByTime = false + THEN o.create_date +END DESC + +LIMIT :limit +OFFSET :offset +""") + fun findRecentOrdersAdmin( + uuid: String?, + symbol: String?, + ouid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + ascendingByTime: Boolean, + limit: Int?, + offset: Int? + ): Flux } \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt index e7e65c16f..c0eacbe01 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt @@ -1,15 +1,9 @@ package co.nilin.opex.market.ports.postgres.dao -import co.nilin.opex.market.core.inout.BestPrice -import co.nilin.opex.market.core.inout.PriceStat -import co.nilin.opex.market.core.inout.TradeVolumeStat -import co.nilin.opex.market.core.inout.Transaction -import co.nilin.opex.market.ports.postgres.model.CandleInfoData -import co.nilin.opex.market.ports.postgres.model.LastPrice -import co.nilin.opex.market.ports.postgres.model.TradeModel -import co.nilin.opex.market.ports.postgres.model.TradeTickerData -import kotlinx.coroutines.flow.Flow -import org.springframework.data.domain.Pageable +import co.nilin.opex.market.core.inout.* +import co.nilin.opex.market.ports.postgres.data.MarketTradeProjection +import co.nilin.opex.market.ports.postgres.data.TradeUserContextProjection +import co.nilin.opex.market.ports.postgres.model.* import org.springframework.data.r2dbc.repository.Query import org.springframework.data.repository.query.Param import org.springframework.data.repository.reactive.ReactiveCrudRepository @@ -23,46 +17,165 @@ import java.util.* interface TradeRepository : ReactiveCrudRepository { @Query("select * from trades where :ouid in (taker_ouid, maker_ouid)") - fun findByOuid(@Param("ouid") ouid: String): Flow + fun findByOuid(@Param("ouid") ouid: String): Flux @Query("select * from trades where symbol = :symbol order by create_date desc limit 1") - fun findMostRecentBySymbol(symbol: String): Flow + fun findMostRecentBySymbol(symbol: String): Flux + + @Query("select * from trades where symbol = :symbol order by create_date desc limit :limit") + fun findBySymbolSortDescendingByCreateDate( + @Param("symbol") + symbol: String, + @Param("limit") + limit: Int, + ): Flux @Query( - """ - select * from trades where :uuid in (taker_uuid, maker_uuid) - and (:fromTrade is null or id > :fromTrade) - and (:symbol is null or symbol = :symbol) - and (:startTime is null or trade_date >= :startTime) - and (:endTime is null or trade_date < :endTime) - order by trade_date DESC - limit :limit """ + SELECT + t.symbol AS symbol, + t.base_asset AS base_asset, + t.quote_asset AS quote_asset, + t.trade_id AS id, + t.matched_price AS price, + t.matched_quantity AS quantity, + + COALESCE( + CASE + WHEN mo.side = 'BID' + THEN mo.quote_quantity + ELSE to2.quote_quantity + END, + 0 + ) AS quote_quantity, + + t.create_date AS create_date, + + CASE + WHEN mo.side = 'BID' + THEN TRUE + ELSE FALSE + END AS is_maker_buyer + + FROM trades t + + LEFT JOIN orders mo + ON mo.ouid = t.maker_ouid + + LEFT JOIN orders to2 + ON to2.ouid = t.taker_ouid + + WHERE (:symbol IS NULL OR t.symbol = :symbol) + + ORDER BY t.trade_date DESC + LIMIT :limit + """ ) - fun findByUuidAndSymbolAndTimeBetweenAndTradeIdGreaterThan( - @Param("uuid") - uuid: String, - @Param("symbol") - symbol: String?, - @Param("fromTrade") - fromTrade: Long?, - @Param("startTime") - startTime: Date?, - @Param("endTime") - endTime: Date?, - limit: Int - ): Flow + fun findRecentMarketTrades( + @Param("symbol") symbol: String?, + @Param("limit") limit: Int + ): Flux + + + @Query(""" + SELECT + t.symbol AS symbol, + + NULL AS baseAsset, + NULL AS quoteAsset, + + t.trade_id AS id, + + CASE + WHEN t.taker_uuid = :uuid + THEN t.taker_price + ELSE t.maker_price + END AS price, + + t.matched_quantity AS quantity, + + COALESCE( + CASE + WHEN mo.side = 'BID' + THEN mo.quote_quantity + ELSE to2.quote_quantity + END, + 0 + ) AS quoteQuantity, + + t.create_date AS createDate, + + COALESCE( + CASE + WHEN mo.side = 'BID' + THEN TRUE + ELSE FALSE + END, + FALSE + ) AS isMakerBuyer, + + CASE + WHEN t.taker_uuid = :uuid + THEN to2.ouid + ELSE mo.ouid + END AS ouid, + + CASE + WHEN t.taker_uuid = :uuid + THEN t.taker_commission + ELSE t.maker_commission + END AS commission, + + CASE + WHEN t.taker_uuid = :uuid + THEN t.taker_commission_asset + ELSE t.maker_commission_asset + END AS commissionAsset, + + CASE + WHEN t.taker_uuid = :uuid + THEN (to2.side = 'ASK') + ELSE (mo.side = 'ASK') + END AS isBuyer, + + (t.maker_uuid = :uuid) AS isMaker + + FROM trades t + + LEFT JOIN orders mo + ON mo.ouid = t.maker_ouid + + LEFT JOIN orders to2 + ON to2.ouid = t.taker_ouid + + WHERE :uuid IN (t.taker_uuid, t.maker_uuid) + AND (:fromTrade IS NULL OR t.trade_id > :fromTrade) + AND (:symbol IS NULL OR t.symbol = :symbol) + AND (:startTime IS NULL OR t.trade_date >= :startTime) + AND (:endTime IS NULL OR t.trade_date < :endTime) + + ORDER BY t.trade_date DESC + LIMIT :limit + """ + ) + fun findTradesWithUserContext( + @Param("uuid") + uuid: String, + @Param("symbol") + symbol: String?, + @Param("fromTrade") + fromTrade: Long?, + @Param("startTime") + startTime: Date?, + @Param("endTime") + endTime: Date?, + @Param("limit") + limit: Int + ): Flux - @Query("select * from trades where symbol = :symbol order by create_date desc limit :limit") - fun findBySymbolSortDescendingByCreateDate( - @Param("symbol") - symbol: String, - @Param("limit") - limit: Int - ): Flow @Query( - """ + """ with first_trade as (select id, symbol, matched_price, matched_quantity from trades where id in (select min(id) from trades where create_date > :date group by symbol)), last_trade as (select id, symbol, matched_price, matched_quantity from trades where id in (select max(id) from trades where create_date > :date group by symbol)) select symbol, @@ -103,7 +216,7 @@ interface TradeRepository : ReactiveCrudRepository { fun tradeTicker(@Param("date") createDate: LocalDateTime): Flux @Query( - """ + """ with first_trade as (select * from trades where create_date > :date and symbol = :symbol order by create_date limit 1), last_trade as (select * from trades where create_date > :date and symbol = :symbol order by create_date desc limit 1) select symbol, @@ -142,14 +255,14 @@ interface TradeRepository : ReactiveCrudRepository { """ ) fun tradeTickerBySymbol( - @Param("symbol") - symbol: String, - @Param("date") - createDate: LocalDateTime, + @Param("symbol") + symbol: String, + @Param("date") + createDate: LocalDateTime, ): Mono @Query( - """ + """ select symbol, ( select price from orders @@ -170,7 +283,7 @@ interface TradeRepository : ReactiveCrudRepository { fun bestAskAndBidPrice(): Flux @Query( - """ + """ select symbol, ( select price from orders @@ -192,7 +305,7 @@ interface TradeRepository : ReactiveCrudRepository { fun bestAskAndBidPrice(symbols: List): Flux @Query( - """ + """ select symbol, ( select price from orders @@ -220,20 +333,43 @@ interface TradeRepository : ReactiveCrudRepository { fun findAllGroupBySymbol(): Flux @Query( - """ - WITH intervals AS (SELECT * FROM interval_generator((:startTime), (:endTime), :interval ::INTERVAL)), - first_trade AS ( - SELECT DISTINCT ON (f.start_time) f.start_time, f.end_time, t.matched_price AS open_price FROM intervals f - LEFT JOIN trades t ON t.create_date >= f.start_time AND t.create_date < f.end_time AND t.symbol = :symbol - ORDER BY f.start_time, t.create_date - ), last_trade AS ( - SELECT DISTINCT ON (f.start_time) f.start_time, f.end_time, t.matched_price AS close_price FROM intervals f - LEFT JOIN trades t ON t.create_date >= f.start_time AND t.create_date < f.end_time AND t.symbol = :symbol - ORDER BY f.start_time, t.create_date DESC + """ + WITH intervals AS ( + SELECT * + FROM interval_generator( + (:startTime)::TIMESTAMP WITHOUT TIME ZONE, + (:endTime)::TIMESTAMP WITHOUT TIME ZONE, + :interval::INTERVAL ) + ), + first_trade AS ( + SELECT DISTINCT ON (f.start_time) + f.start_time, + f.end_time, + t.matched_price AS open_price + FROM intervals f + LEFT JOIN trades t + ON t.create_date >= f.start_time + AND t.create_date < f.end_time + AND t.symbol = :symbol + ORDER BY f.start_time, t.create_date + ), + last_trade AS ( + SELECT DISTINCT ON (f.start_time) + f.start_time, + f.end_time, + t.matched_price AS close_price + FROM intervals f + LEFT JOIN trades t + ON t.create_date >= f.start_time + AND t.create_date < f.end_time + AND t.symbol = :symbol + ORDER BY f.start_time, t.create_date DESC + ), + ohlcv AS ( SELECT i.start_time AS open_time, - i.end_time AS close_time, + i.end_time AS close_time, ft.open_price AS open, MAX(t.matched_price) AS high, MIN(t.matched_price) AS low, @@ -242,26 +378,36 @@ interface TradeRepository : ReactiveCrudRepository { COUNT(t.id) AS trades FROM intervals i LEFT JOIN trades t - ON t.create_date >= i.start_time AND t.create_date < i.end_time AND t.symbol = :symbol + ON t.create_date >= i.start_time + AND t.create_date < i.end_time + AND t.symbol = :symbol LEFT JOIN first_trade ft - ON i.start_time = ft.start_time + ON i.start_time = ft.start_time LEFT JOIN last_trade lt - ON i.start_time = lt.start_time + ON i.start_time = lt.start_time GROUP BY i.start_time, i.end_time, ft.open_price, lt.close_price - ORDER BY i.start_time; - """ + ) + SELECT * + FROM ( + SELECT * + FROM ohlcv + ORDER BY open_time DESC + limit :limit + ) sub + ORDER BY open_time ASC +""" ) suspend fun candleData( - @Param("symbol") - symbol: String, - @Param("interval") - interval: String, - @Param("startTime") - startTime: LocalDateTime, - @Param("endTime") - endTime: LocalDateTime, - @Param("limit") - limit: Int, + @Param("symbol") + symbol: String, + @Param("interval") + interval: String, + @Param("startTime") + startTime: LocalDateTime, + @Param("endTime") + endTime: LocalDateTime, + @Param("limit") + limit: Int, ): Flux @Query("select * from trades order by create_date desc limit 1") @@ -271,13 +417,13 @@ interface TradeRepository : ReactiveCrudRepository { suspend fun findFirstByCreateDate(): Mono @Query("select count(*) from trades where create_date >= :interval") - fun countNewerThan(interval: LocalDateTime): Flow + fun countNewerThan(interval: LocalDateTime): Flux @Query("select count(*) from trades where symbol = :symbol and create_date >= :interval") - fun countBySymbolNewerThan(interval: LocalDateTime, symbol: String): Flow + fun countBySymbolNewerThan(interval: LocalDateTime, symbol: String): Flux @Query( - """ + """ WITH first_trade AS (SELECT symbol, MIN(id) AS min_id FROM trades WHERE create_date > :since GROUP BY symbol), last_trade AS (SELECT symbol, MAX(id) AS max_id FROM trades WHERE create_date > :since GROUP BY symbol), first_trade_details AS (SELECT ft.symbol, t.matched_price AS first_price FROM first_trade ft JOIN trades t ON ft.min_id = t.id), @@ -299,7 +445,7 @@ interface TradeRepository : ReactiveCrudRepository { fun findByMostIncreasedPrice(since: LocalDateTime, limit: Int): Flux @Query( - """ + """ WITH first_trade AS (SELECT symbol, MIN(id) AS min_id FROM trades WHERE create_date > :since GROUP BY symbol), last_trade AS (SELECT symbol, MAX(id) AS max_id FROM trades WHERE create_date > :since GROUP BY symbol), first_trade_details AS (SELECT ft.symbol, t.matched_price AS first_price FROM first_trade ft JOIN trades t ON ft.min_id = t.id), @@ -321,7 +467,7 @@ interface TradeRepository : ReactiveCrudRepository { fun findByMostDecreasedPrice(since: LocalDateTime, limit: Int): Flux @Query( - """ + """ with first_trade as (select symbol, matched_quantity mq from trades where id in (select min(id) from trades where create_date > :since group by symbol)), last_trade as (select symbol, matched_quantity mq from trades where id in (select max(id) from trades where create_date > :since group by symbol)) select @@ -345,7 +491,7 @@ interface TradeRepository : ReactiveCrudRepository { fun findByMostVolume(since: LocalDateTime): Mono @Query( - """ + """ with first_trade as (select symbol, matched_quantity mq from trades where id in (select min(id) from trades where create_date > :since group by symbol)), last_trade as (select symbol, matched_quantity mq from trades where id in (select max(id) from trades where create_date > :since group by symbol)) select @@ -369,7 +515,8 @@ interface TradeRepository : ReactiveCrudRepository { fun findByMostTrades(since: LocalDateTime): Mono - @Query(""" select t.trade_date As create_date, + @Query( + """ select t.trade_date As create_date, t.matched_quantity AS volume, t.matched_price AS matched_price, CASE @@ -411,13 +558,20 @@ interface TradeRepository : ReactiveCrudRepository { and (:startDate is null or trade_date >=:startDate) and (:endDate is null or trade_date <=:endDate) - order by create_date ASC offset :offset limit :limit """) - - fun findTxOfTradesAsc(user: String, startDate: LocalDateTime?, endDate: LocalDateTime?, offset: Int?, limit: Int?): Flux + order by create_date ASC offset :offset limit :limit """ + ) + fun findTxOfTradesAsc( + user: String, + startDate: LocalDateTime?, + endDate: LocalDateTime?, + offset: Int?, + limit: Int?, + ): Flux - @Query(""" select t.trade_date As create_date, + @Query( + """ select t.trade_date As create_date, t.matched_quantity AS volume, t.matched_price AS matched_price, CASE @@ -459,9 +613,209 @@ interface TradeRepository : ReactiveCrudRepository { and (:startDate is null or trade_date >=:startDate) and (:endDate is null or trade_date <=:endDate) - order by create_date DESC offset :offset limit :limit """) + order by create_date DESC offset :offset limit :limit """ + ) - fun findTxOfTradesDesc(user: String, startDate: LocalDateTime?, endDate: LocalDateTime?, offset: Int?, limit: Int?): Flux + fun findTxOfTradesDesc( + user: String, + startDate: LocalDateTime?, + endDate: LocalDateTime?, + offset: Int?, + limit: Int?, + ): Flux + @Query( + """ + WITH intervals AS (SELECT * FROM interval_generator((:startTime), (:endTime), :interval ::INTERVAL)), + last_trade AS ( + SELECT DISTINCT ON (f.start_time) f.start_time, f.end_time, t.matched_price AS close_price FROM intervals f + LEFT JOIN trades t ON t.create_date >= f.start_time AND t.create_date < f.end_time AND t.symbol = :symbol + ORDER BY f.start_time, t.create_date DESC + ) + SELECT + i.end_time AS close_time, + lt.close_price AS close_price + FROM intervals i + LEFT JOIN trades t + ON t.create_date >= i.start_time AND t.create_date < i.end_time AND t.symbol = :symbol + LEFT JOIN last_trade lt + ON i.start_time = lt.start_time + GROUP BY i.start_time, i.end_time, lt.close_price + ORDER BY i.start_time; + """ + ) + suspend fun getPriceTimeData( + @Param("symbol") + symbol: String, + @Param("interval") + interval: String, + @Param("startTime") + startTime: LocalDateTime, + @Param("endTime") + endTime: LocalDateTime, + ): Flux + + + @Query( + """ + select * from trades where + (:symbol is null or symbol = :symbol) + and (:makerUuid is null or maker_uuid = :makerUuid) + and (:takerUuid is null or taker_uuid = :takerUuid) + and (:fromDate is null or trade_date >= :fromDate) + and (:toDate is null or trade_date <= :toDate) + and (:excludeSelfTrade is false or maker_uuid != taker_uuid) + order by trade_date DESC + limit :limit + offset :offset + """ + ) + suspend fun findByCriteria( + symbol: String?, + makerUuid: String?, + takerUuid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + excludeSelfTrade: Boolean, + limit: Int, + offset: Int, + ): Flux + @Query( + """ + select * from trades where + (:symbol is null or symbol=:symbol) + and (:baseAsset is null or base_asset = :baseAsset) + and (:quoteAsset is null or quote_asset = :quoteAsset) + and (:uuid is null or :uuid in (maker_uuid, taker_uuid)) + and (:makerUuid is null or maker_uuid = :makerUuid) + and (:takerUuid is null or taker_uuid = :takerUuid) + and (:ouid is null or :ouid in (maker_ouid, taker_ouid)) + and (:makerOuid is null or maker_ouid = :makerOuid) + and (:takerOuid is null or taker_ouid = :takerOuid) + and (:fromDate is null or trade_date >= :fromDate) + and (:toDate is null or trade_date <= :toDate) + and (:excludeSelfTrade is false or maker_uuid != taker_uuid) + order by trade_date ASC + limit :limit + offset :offset + """ + ) + suspend fun findByCriteriaByBaseQuoteAsc( + symbol: String?, + baseAsset: String?, + quoteAsset: String?, + uuid: String?, + makerUuid: String?, + takerUuid: String?, + ouid: String?, + makerOuid: String?, + takerOuid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + excludeSelfTrade: Boolean, + limit: Int? = 10, + offset: Int? = 0, + ): Flux + + @Query( + """ + select * from trades where + (:symbol is null or symbol=:symbol) + and (:baseAsset is null or base_asset = :baseAsset) + and (:quoteAsset is null or quote_asset = :quoteAsset) + and (:uuid is null or :uuid in (maker_uuid, taker_uuid)) + and (:makerUuid is null or maker_uuid = :makerUuid) + and (:takerUuid is null or taker_uuid = :takerUuid) + and (:ouid is null or :ouid in (maker_ouid, taker_ouid)) + and (:makerOuid is null or maker_ouid = :makerOuid) + and (:takerOuid is null or taker_ouid = :takerOuid) + and (:fromDate is null or trade_date >= :fromDate) + and (:toDate is null or trade_date <= :toDate) + and (:excludeSelfTrade is false or maker_uuid != taker_uuid) + order by trade_date DESC + limit :limit + offset :offset + """ + ) + suspend fun findByCriteriaByBaseQuoteDesc( + symbol: String?, + baseAsset: String?, + quoteAsset: String?, + uuid: String?, + makerUuid: String?, + takerUuid: String?, + ouid: String?, + makerOuid: String?, + takerOuid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + excludeSelfTrade: Boolean, + limit: Int? = 10, + offset: Int? = 0, + ): Flux + + + @Query( + """ +select t.symbol, + t.id, + o.order_id, + case when :uuid = t.maker_uuid then t.maker_price else t.taker_price end as price, + t.matched_quantity as quantity, + o.quote_quantity, + case when :uuid = t.maker_uuid then t.maker_commission else t.taker_commission end as commission, + case + when :uuid = t.maker_uuid then t.maker_commission_asset + else t.taker_commission_asset end as commission_asset, + t.trade_date as time, + o.side = 'BID' as is_buyer, + t.maker_uuid = :uuid as is_maker, + true as is_best_match, + case when o.side = 'BID' and t.maker_uuid = :uuid then true else false end as is_maker_buyer +from trades t + inner join orders o on + (t.maker_uuid = :uuid and o.ouid = t.maker_ouid) or + (t.taker_uuid = :uuid and o.ouid = t.taker_ouid) +where (:uuid is null or :uuid in (t.maker_uuid, t.taker_uuid)) + and (:symbol is null or t.symbol = :symbol) + and (:startTime is null or t.trade_date >= :startTime) + and (:endTime is null or t.trade_date <= :endTime) + and (:direction is null or o.side = :direction) + order by t.trade_date desc + limit :limit + offset :offset + """ + ) + suspend fun findByCriteria( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): Flux + + @Query( + """ +select count(*) +from trades t + inner join orders o on + (t.maker_uuid = :uuid and o.ouid = t.maker_ouid) or + (t.taker_uuid = :uuid and o.ouid = t.taker_ouid) +where (:uuid is null or :uuid in (t.maker_uuid, t.taker_uuid) ) + and (:symbol is null or t.symbol = :symbol) + and (:startTime is null or t.trade_date >= :startTime) + and (:endTime is null or t.trade_date <= :endTime) + and (:direction is null or o.side = :direction) + """ + ) + suspend fun countByCriteria( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + direction: OrderDirection?, + ): Mono } \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/data/MarketTradeProjection.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/data/MarketTradeProjection.kt new file mode 100644 index 000000000..f24cc5c63 --- /dev/null +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/data/MarketTradeProjection.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.market.ports.postgres.data + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class MarketTradeProjection( + val symbol: String, + val baseAsset: String, + val quoteAsset: String, + val id: Long, + val price: BigDecimal, + val quantity: BigDecimal, + val quoteQuantity: BigDecimal, + val createDate: LocalDateTime, + val isMakerBuyer: Boolean +) \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/data/TradeUserContextProjection.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/data/TradeUserContextProjection.kt new file mode 100644 index 000000000..5ff2a61a5 --- /dev/null +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/data/TradeUserContextProjection.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.market.ports.postgres.data + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class TradeUserContextProjection( + val symbol: String, + val baseAsset: String?, + val quoteAsset: String?, + val id: Long, + val price: BigDecimal, + val quantity: BigDecimal, + val quoteQuantity: BigDecimal, + val createDate: LocalDateTime, + val isMakerBuyer: Boolean, + val ouid: String? = null, + val commission: BigDecimal? = null, + val commissionAsset: String? = null, + val isBuyer: Boolean? = null, + val isMaker: Boolean? = null +) \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt index 477a4d770..fe8673002 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt @@ -11,30 +11,27 @@ import co.nilin.opex.market.ports.postgres.dao.TradeRepository import co.nilin.opex.market.ports.postgres.model.TradeTickerData import co.nilin.opex.market.ports.postgres.util.RedisCacheHelper import co.nilin.opex.market.ports.postgres.util.asOrderDTO -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.singleOrNull -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.springframework.stereotype.Component -import java.lang.StringBuilder import java.math.BigDecimal import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.util.* + @Component class MarketQueryHandlerImpl( private val orderRepository: OrderRepository, private val tradeRepository: TradeRepository, private val orderStatusRepository: OrderStatusRepository, - private val redisCacheHelper: RedisCacheHelper + private val redisCacheHelper: RedisCacheHelper, ) : MarketQueryHandler { - //TODO merge order and status fetching in query override suspend fun getTradeTickerData(interval: Interval): List { return redisCacheHelper.getOrElse("tradeTickerData:${interval.label}", 2.minutes()) { @@ -49,20 +46,19 @@ class MarketQueryHandlerImpl( val cacheId = "tradeTickerData:$symbol:${interval.label}" return redisCacheHelper.getOrElse(cacheId, 2.minutes()) { tradeRepository.tradeTickerBySymbol(symbol, interval.getLocalDateTime()) - .awaitFirstOrNull() + .awaitSingleOrNull() ?.asPriceChangeResponse(Date().time, interval.getTime()) } } - override suspend fun openBidOrders(symbol: String, limit: Int): List { + override suspend fun openBidOrders(symbol: String, limit: Int): List? { return orderRepository.findBySymbolAndDirectionAndStatusSortDescendingByPrice( symbol, OrderDirection.BID, limit, listOf(OrderStatus.NEW.code, OrderStatus.PARTIALLY_FILLED.code) - ).collectList() - .awaitFirstOrElse { emptyList() } - .map { OrderBook(it.price, it.quantity) } + ).collectList().awaitSingleOrNull() + ?.map { OrderBook(it.price, it.quantity) } } override suspend fun openAskOrders(symbol: String, limit: Int): List { @@ -80,42 +76,185 @@ class MarketQueryHandlerImpl( // @Cacheable(cacheNames = ["marketCache"], key = "'lastOrder'") override suspend fun lastOrder(symbol: String): Order? { return redisCacheHelper.get("lastOrder") ?: run { - val order = orderRepository.findLastOrderBySymbol(symbol).awaitFirstOrNull() ?: return@run null - val status = orderStatusRepository.findMostRecentByOUID(order.ouid).awaitFirstOrNull() + val order = orderRepository.findLastOrderBySymbol(symbol).awaitSingleOrNull() ?: return@run null + val status = orderStatusRepository.findMostRecentByOUID(order.ouid).awaitSingleOrNull() order.asOrderDTO(status) }.also { redisCacheHelper.put("lastOrder", it) } } - //TODO need better query - override suspend fun recentTrades(symbol: String, limit: Int): List { - val cacheKey = "recentTrades:${symbol.lowercase()}" - val recentTradesCache = redisCacheHelper.getList(cacheKey) - if (!recentTradesCache.isNullOrEmpty()) - return recentTradesCache.toList() + override suspend fun recentTrades(symbol: String, limit: Int): List? { - return tradeRepository.findBySymbolSortDescendingByCreateDate(symbol, limit) + return tradeRepository + .findRecentMarketTrades(symbol, limit) .map { - val takerOrder = orderRepository.findByOuid(it.takerOuid).awaitFirst() - val makerOrder = orderRepository.findByOuid(it.makerOuid).awaitFirst() - val isMakerBuyer = makerOrder.direction == OrderDirection.BID MarketTrade( - it.symbol, - it.baseAsset, - it.quoteAsset, - it.tradeId, - it.matchedPrice, - it.matchedQuantity, - if (isMakerBuyer) - makerOrder.quoteQuantity!! - else - takerOrder.quoteQuantity!!, + symbol = it.symbol, + baseAsset = it.baseAsset, + quoteAsset = it.quoteAsset, + id = it.id, + price = it.price, + quantity = it.quantity, + quoteQuantity = it.quoteQuantity, Date.from(it.createDate.atZone(ZoneId.systemDefault()).toInstant()), - true, - isMakerBuyer + isBestMatch = true, + isMakerBuyer = it.isMakerBuyer + ) + } + .collectList() + .awaitSingleOrNull() + } + + override suspend fun recentTrades( + symbol: String?, + makerUuid: String?, + takerUuid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + excludeSelfTrade: Boolean, + limit: Int, + offset: Int, + ): List? { + return tradeRepository.findByCriteria( + symbol, + makerUuid, + takerUuid, + fromDate, + toDate, + excludeSelfTrade, + limit, + offset + ) + .map { + TradeData( + tradeId = it.tradeId, + symbol = it.symbol, + baseAsset = it.baseAsset, + quoteAsset = it.quoteAsset, + matchedPrice = it.matchedPrice, + matchedQuantity = it.matchedQuantity, + takerPrice = it.takerPrice, + makerPrice = it.makerPrice, + tradeDate = it.tradeDate, + makerUuid = it.makerUuid, + takerUuid = it.takerUuid, + makerOuid = it.makerOuid, + takerOuid = it.takerOuid, + makerCommission = it.makerCommission, + takerCommission = it.takerCommission, + makerCommissionAsset = it.makerCommissionAsset, + takerCommissionAsset = it.takerCommissionAsset, + ) + } + .collectList().awaitSingleOrNull() + } + + + override suspend fun recentTradesAdmin( + symbol: String?, + baseAsset: String?, + quoteAsset: String?, + uuid: String?, + makerUuid: String?, + takerUuid: String?, + ouid: String?, + makerOuid: String?, + takerOuid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + excludeSelfTrade: Boolean, + ascendingByTime: Boolean, + limit: Int?, + offset: Int?, + ): List? { + val flow = if (ascendingByTime) { + tradeRepository.findByCriteriaByBaseQuoteAsc( + symbol, + baseAsset, + quoteAsset, + uuid, + makerUuid, + takerUuid, + ouid, + makerOuid, + takerOuid, + fromDate, + toDate, + excludeSelfTrade, + limit, + offset + ) + } else { + tradeRepository.findByCriteriaByBaseQuoteDesc( + symbol, + baseAsset, + quoteAsset, + uuid, + makerUuid, + takerUuid, + ouid, + makerOuid, + takerOuid, + fromDate, + toDate, + excludeSelfTrade, + limit, + offset + ) + } + return flow + .map { + TradeData( + tradeId = it.tradeId, + symbol = it.symbol, + baseAsset = it.baseAsset, + quoteAsset = it.quoteAsset, + matchedPrice = it.matchedPrice, + matchedQuantity = it.matchedQuantity, + takerPrice = it.takerPrice, + makerPrice = it.makerPrice, + tradeDate = it.tradeDate, + makerUuid = it.makerUuid, + takerUuid = it.takerUuid, + makerOuid = it.makerOuid, + takerOuid = it.takerOuid, + makerCommission = it.makerCommission, + takerCommission = it.takerCommission, + makerCommissionAsset = it.makerCommissionAsset, + takerCommissionAsset = it.takerCommissionAsset, ) - }.toList() - .onEach { redisCacheHelper.putListItem(cacheKey, it) } - .also { redisCacheHelper.setExpiration(cacheKey, 60.minutes()) } + }.collectList().awaitSingleOrNull() + + } + + override suspend fun recentOrdersAdmin( + uuid: String?, + symbol: String?, + ouid: String?, + fromDate: LocalDateTime?, + toDate: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + ascendingByTime: Boolean, + limit: Int?, + offset: Int? + ): List { + + return orderRepository + .findRecentOrdersAdmin( + uuid, + symbol, + ouid, + fromDate, + toDate, + orderType, + direction, + ascendingByTime, + limit, + offset + ) + .map { it.copy(status = OrderStatus.fromCode(it.statusCode)) } + .collectList() + .awaitSingle() } override suspend fun lastPrice(symbol: String?): List { @@ -142,17 +281,17 @@ class MarketQueryHandlerImpl( interval: String, startTime: Long?, endTime: Long?, - limit: Int + limit: Int, ): List { val st = if (startTime == null) - tradeRepository.findFirstByCreateDate().awaitFirstOrNull()?.createDate ?: LocalDateTime.now() + tradeRepository.findFirstByCreateDate().awaitSingleOrNull()?.createDate ?: LocalDateTime.now() else with(Instant.ofEpochMilli(startTime)) { LocalDateTime.ofInstant(this, ZoneId.systemDefault()) } val et = if (endTime == null) - tradeRepository.findLastByCreateDate().awaitFirstOrNull()?.createDate ?: LocalDateTime.now() + tradeRepository.findLastByCreateDate().awaitSingleOrNull()?.createDate ?: LocalDateTime.now() else with(Instant.ofEpochMilli(endTime)) { LocalDateTime.ofInstant(this, ZoneId.systemDefault()) @@ -190,12 +329,13 @@ class MarketQueryHandlerImpl( override suspend fun numberOfTrades(interval: Interval, pair: String?): Long { return if (pair != null) redisCacheHelper.getOrElse("tradeCount:$pair:${interval.label}", 1.hours()) { - tradeRepository.countBySymbolNewerThan(interval.getLocalDateTime(), pair).singleOrNull()?.approximate() + tradeRepository.countBySymbolNewerThan(interval.getLocalDateTime(), pair).awaitFirstOrNull() + ?.approximate() ?: 0 } else redisCacheHelper.getOrElse("tradeCount:${interval.label}", 1.hours()) { - tradeRepository.countNewerThan(interval.getLocalDateTime()).singleOrNull()?.approximate() ?: 0 + tradeRepository.countNewerThan(interval.getLocalDateTime()).awaitFirstOrNull()?.approximate() ?: 0 } } @@ -239,6 +379,64 @@ class MarketQueryHandlerImpl( } } + override suspend fun getWeeklyPriceData(symbol: String): List { + return getPriceDataWithCache( + symbol = symbol, + cacheKeyPrefix = "weeklyPriceData", + interval = "4h", + fromDate = LocalDateTime.now().minusDays(7) + ) + } + + override suspend fun getMonthlyPriceData(symbol: String): List { + return getPriceDataWithCache( + symbol = symbol, + cacheKeyPrefix = "monthlyPriceData", + interval = "24h", + fromDate = LocalDateTime.now().minusDays(30) + ) + } + + override suspend fun getDailyPriceData(symbol: String): List { + return getPriceDataWithCache( + symbol = symbol, + cacheKeyPrefix = "dailyPriceData", + interval = "1h", + fromDate = LocalDateTime.now().minusDays(1) + ) + } + + private suspend fun getPriceDataWithCache( + symbol: String, + cacheKeyPrefix: String, + interval: String, + fromDate: LocalDateTime, + ): List { + val cacheKey = "${cacheKeyPrefix}:${symbol.lowercase()}" + val cachedData = redisCacheHelper.getList(cacheKey) + if (!cachedData.isNullOrEmpty()) { + return cachedData.toList() + } + + return tradeRepository.getPriceTimeData(symbol, interval, fromDate, LocalDateTime.now()) + .collectList() + .awaitFirstOrElse { emptyList() } + .let { priceTimes -> + var lastNonNullPrice: BigDecimal? = null + val firstNonNullPrice = priceTimes.firstOrNull { it.closePrice != null }?.closePrice ?: BigDecimal.ZERO + priceTimes.map { item -> + val price = item.closePrice ?: lastNonNullPrice ?: firstNonNullPrice + lastNonNullPrice = price + PriceTime( + item.closeTime, + price + ) + } + .onEach { redisCacheHelper.putListItem(cacheKey, it) } + .also { redisCacheHelper.setExpiration(cacheKey, 1.hours()) } + } + } + private fun TradeTickerData.asPriceChangeResponse(openTime: Long, closeTime: Long) = PriceChange( symbol, priceChange ?: BigDecimal.ZERO, diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketRateServiceImpl.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketRateServiceImpl.kt deleted file mode 100644 index 61c687227..000000000 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketRateServiceImpl.kt +++ /dev/null @@ -1,31 +0,0 @@ -package co.nilin.opex.market.ports.postgres.impl - -import co.nilin.opex.market.core.inout.CurrencyRate -import co.nilin.opex.market.core.inout.RateSource -import co.nilin.opex.market.core.spi.MarketRateService -import co.nilin.opex.market.ports.postgres.dao.CurrencyRateRepository -import kotlinx.coroutines.reactive.awaitFirstOrElse -import kotlinx.coroutines.reactor.awaitSingleOrNull -import org.springframework.stereotype.Component -import java.math.BigDecimal - -@Component -class MarketRateServiceImpl(private val rateRepository: CurrencyRateRepository) : MarketRateService { - - override suspend fun currencyRate(quote: String, source: RateSource): List { - return rateRepository.findAllByQuoteAndSource(quote, source) - .collectList() - .awaitFirstOrElse { emptyList() } - .map { CurrencyRate(it.base, it.quote, it.source, it.rate) } - } - - override suspend fun currencyRate(base: String, quote: String, source: RateSource): CurrencyRate { - val rate = rateRepository.findByBaseAndQuoteAndSource(base, quote, source).awaitSingleOrNull() - return CurrencyRate( - base, - quote, - source, - rate?.rate ?: BigDecimal.ZERO - ) - } -} \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterImpl.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterImpl.kt index dbefd9f26..c6870c2cf 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterImpl.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterImpl.kt @@ -5,6 +5,7 @@ import co.nilin.opex.market.core.event.RichOrder import co.nilin.opex.market.core.event.RichOrderUpdate import co.nilin.opex.market.core.inout.Order import co.nilin.opex.market.core.inout.OrderStatus +import co.nilin.opex.market.core.spi.MarketOrderProducer import co.nilin.opex.market.core.spi.OrderPersister import co.nilin.opex.market.ports.postgres.dao.OpenOrderRepository import co.nilin.opex.market.ports.postgres.dao.OrderRepository @@ -25,7 +26,8 @@ class OrderPersisterImpl( private val orderRepository: OrderRepository, private val orderStatusRepository: OrderStatusRepository, private val openOrderRepository: OpenOrderRepository, - private val redisCacheHelper: RedisCacheHelper + private val redisCacheHelper: RedisCacheHelper, + private val marketOrderProducer: MarketOrderProducer ) : OrderPersister { private val logger = LoggerFactory.getLogger(OrderPersisterImpl::class.java) @@ -75,6 +77,7 @@ class OrderPersisterImpl( logger.info("Order ${order.ouid} deleted from open orders") } + marketOrderProducer.openOrderUpdate(order.uuid, order.pair) justTry { redisCacheHelper.put("lastOrder", orderModel.asOrderDTO(lastStatus)) } } @@ -98,6 +101,8 @@ class OrderPersisterImpl( openOrderRepository.delete(orderUpdate.ouid).awaitSingleOrNull() logger.info("Order ${orderUpdate.ouid} deleted from open orders") } + val order = orderRepository.findByOuid(orderUpdate.ouid).awaitFirstOrNull() ?: return + marketOrderProducer.openOrderUpdate(order.uuid, order.symbol) } override suspend fun load(ouid: String): Order? { diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterImpl.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterImpl.kt index bb3f05257..b229535a4 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterImpl.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterImpl.kt @@ -3,9 +3,7 @@ package co.nilin.opex.market.ports.postgres.impl import co.nilin.opex.market.core.event.RichTrade import co.nilin.opex.market.core.inout.MarketTrade import co.nilin.opex.market.core.inout.OrderDirection -import co.nilin.opex.market.core.inout.RateSource import co.nilin.opex.market.core.spi.TradePersister -import co.nilin.opex.market.ports.postgres.dao.CurrencyRateRepository import co.nilin.opex.market.ports.postgres.dao.TradeRepository import co.nilin.opex.market.ports.postgres.model.TradeModel import co.nilin.opex.market.ports.postgres.util.RedisCacheHelper @@ -20,7 +18,6 @@ import java.util.* @Component class TradePersisterImpl( private val tradeRepository: TradeRepository, - private val currencyRateRepository: CurrencyRateRepository, private val redisCacheHelper: RedisCacheHelper, ) : TradePersister { @@ -54,15 +51,12 @@ class TradePersisterImpl( ) ).awaitFirstOrNull() logger.info("RichTrade ${trade.id} saved") + //calculateTradeVolume(trade, pair[0].uppercase(), pair[1].uppercase()) // Moved to accountant + updateCache(trade, tradeEntity) + } - currencyRateRepository.createOrUpdate( - pair[0].uppercase(), - pair[1].uppercase(), - RateSource.MARKET, - trade.matchedPrice - ).awaitFirstOrNull() - logger.info("Rate between ${pair[0]} and ${pair[1]} updated") + private fun updateCache(trade: RichTrade, tradeEntity: TradeModel?) { try { if (tradeEntity == null || !redisCacheHelper.hasKey("recentTrades:${trade.pair.lowercase()}")) return val isMakerBuyer = trade.makerDirection == OrderDirection.BID @@ -90,6 +84,5 @@ class TradePersisterImpl( } catch (e: Exception) { logger.info("Could not update recentTrades cache") } - } } \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerImpl.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerImpl.kt index bf2c934d6..2af1e9417 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerImpl.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerImpl.kt @@ -12,29 +12,28 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort import org.springframework.stereotype.Component import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId -import java.util.* @Component class UserQueryHandlerImpl( - private val orderRepository: OrderRepository, - private val tradeRepository: TradeRepository, - private val orderStatusRepository: OrderStatusRepository + private val orderRepository: OrderRepository, + private val tradeRepository: TradeRepository, + private val orderStatusRepository: OrderStatusRepository, ) : UserQueryHandler { //TODO merge order and status fetching in query override suspend fun getOrder(uuid: String, ouid: String): Order? { return orderRepository.findByUUIDAndOUID(uuid, ouid) - .awaitSingleOrNull() - ?.asOrderDTO(orderStatusRepository.findMostRecentByOUID(ouid).awaitFirstOrNull()) + .awaitSingleOrNull() + ?.asOrderDTO(orderStatusRepository.findMostRecentByOUID(ouid).awaitFirstOrNull()) } override suspend fun queryOrder(uuid: String, request: QueryOrderRequest): Order? { @@ -51,74 +50,189 @@ class UserQueryHandlerImpl( return order.asOrderDTO(status) } + override suspend fun openOrders(uuid: String, limit: Int): List { + return orderRepository.findByUuidAndSymbolAndStatus( + uuid, + null, + listOf(OrderStatus.NEW.code, OrderStatus.PARTIALLY_FILLED.code), + limit + ).filter { orderModel -> orderModel.constraint != null } + .map { it.asOrderDTO(orderStatusRepository.findMostRecentByOUID(it.ouid).awaitFirstOrNull()) } + .toList() + } + override suspend fun openOrders(uuid: String, symbol: String?, limit: Int): List { return orderRepository.findByUuidAndSymbolAndStatus( - uuid, - symbol, - listOf(OrderStatus.NEW.code, OrderStatus.PARTIALLY_FILLED.code), - limit + uuid, + symbol, + listOf(OrderStatus.NEW.code, OrderStatus.PARTIALLY_FILLED.code), + limit ).filter { orderModel -> orderModel.constraint != null } - .map { it.asOrderDTO(orderStatusRepository.findMostRecentByOUID(it.ouid).awaitFirstOrNull()) } - .toList() + .map { it.asOrderDTO(orderStatusRepository.findMostRecentByOUID(it.ouid).awaitFirstOrNull()) } + .toList() } override suspend fun allOrders(uuid: String, allOrderRequest: AllOrderRequest): List { return orderRepository.findByUuidAndSymbolAndTimeBetween( - uuid, - allOrderRequest.symbol, - allOrderRequest.startTime, - allOrderRequest.endTime, - allOrderRequest.limit + uuid, + allOrderRequest.symbol, + allOrderRequest.startTime, + allOrderRequest.endTime, + allOrderRequest.limit ).filter { orderModel -> orderModel.constraint != null } - .map { it.asOrderDTO(orderStatusRepository.findMostRecentByOUID(it.ouid).awaitFirstOrNull()) } - .toList() + .map { it.asOrderDTO(orderStatusRepository.findMostRecentByOUID(it.ouid).awaitFirstOrNull()) } + .toList() } - override suspend fun allTrades(uuid: String, request: TradeRequest): List { - return tradeRepository.findByUuidAndSymbolAndTimeBetweenAndTradeIdGreaterThan( - uuid, request.symbol, request.fromTrade, request.startTime, request.endTime, request.limit - ).map { - val takerOrder = orderRepository.findByOuid(it.takerOuid).awaitFirst() - val makerOrder = orderRepository.findByOuid(it.makerOuid).awaitFirst() - val isMakerBuyer = makerOrder.direction == OrderDirection.BID - Trade( + override suspend fun allTrades( + uuid: String, + request: TradeRequest + ): List? { + + return tradeRepository.findTradesWithUserContext( + uuid, + request.symbol, + request.fromTrade, + request.startTime, + request.endTime, + request.limit + ) + .map { + Trade( it.symbol, - it.tradeId, - if (it.takerUuid == uuid) takerOrder.orderId!! else makerOrder.orderId!!, - if (it.takerUuid == uuid) it.takerPrice else it.makerPrice, - it.matchedQuantity, - if (isMakerBuyer) makerOrder.quoteQuantity!! else takerOrder.quoteQuantity!!, - if (it.takerUuid == uuid) it.takerCommission!! else it.makerCommission!!, - if (it.takerUuid == uuid) it.takerCommissionAsset!! else it.makerCommissionAsset!!, - Date.from(it.createDate.atZone(ZoneId.systemDefault()).toInstant()), - if (it.takerUuid == uuid) - OrderDirection.ASK == takerOrder.direction - else - OrderDirection.ASK == makerOrder.direction, - it.makerUuid == uuid, + it.id, + it.ouid, + it.price, + it.quantity, + it.quoteQuantity, + requireNotNull(it.commission), + requireNotNull(it.commissionAsset), + it.createDate, + requireNotNull(it.isBuyer), + requireNotNull(it.isMaker), true, - isMakerBuyer - ) - }.toList() + it.isMakerBuyer + ) + } + .collectList() + .awaitSingle() } override suspend fun txOfTrades(transactionRequest: TransactionRequest): TransactionResponse? { - if (transactionRequest.ascendingByTime == true) - return TransactionResponse(tradeRepository.findTxOfTradesAsc(transactionRequest.owner!!, - transactionRequest.startTime?.let { LocalDateTime.ofInstant(Instant.ofEpochMilli(transactionRequest.startTime!!), ZoneId.systemDefault()) } - ?: null, - transactionRequest.endTime?.let { LocalDateTime.ofInstant(Instant.ofEpochMilli(transactionRequest.endTime!!), ZoneId.systemDefault()) } - ?: null, - transactionRequest.offset, transactionRequest.limit - ).map { it.toDto() }.collectList()?.awaitFirstOrNull()) + val trades = if (transactionRequest.ascendingByTime == true) + + tradeRepository.findTxOfTradesAsc( + transactionRequest.owner!!, + transactionRequest.startTime?.let { + LocalDateTime.ofInstant( + Instant.ofEpochMilli(transactionRequest.startTime!!), + ZoneId.systemDefault() + ) + }, + transactionRequest.endTime?.let { + LocalDateTime.ofInstant( + Instant.ofEpochMilli(transactionRequest.endTime!!), + ZoneId.systemDefault() + ) + }, + transactionRequest.offset, transactionRequest.limit + ).map { it.toDto() }.collectList().awaitFirstOrNull() else - return TransactionResponse(tradeRepository.findTxOfTradesDesc(transactionRequest.owner!!, - transactionRequest.startTime?.let { LocalDateTime.ofInstant(Instant.ofEpochMilli(transactionRequest.startTime!!), ZoneId.systemDefault()) } - ?: null, - transactionRequest.endTime?.let { LocalDateTime.ofInstant(Instant.ofEpochMilli(transactionRequest.endTime!!), ZoneId.systemDefault()) } - ?: null, - transactionRequest.offset, transactionRequest.limit - ).map { it.toDto() }.collectList()?.awaitFirstOrNull()) + + tradeRepository.findTxOfTradesDesc( + transactionRequest.owner!!, + transactionRequest.startTime?.let { + LocalDateTime.ofInstant( + Instant.ofEpochMilli(transactionRequest.startTime!!), + ZoneId.systemDefault() + ) + }, + transactionRequest.endTime?.let { + LocalDateTime.ofInstant( + Instant.ofEpochMilli(transactionRequest.endTime!!), + ZoneId.systemDefault() + ) + }, + transactionRequest.offset, transactionRequest.limit + ).map { it.toDto() }.collectList()?.awaitFirstOrNull() + + return TransactionResponse(trades) + } + + override suspend fun getOrderHistory( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List { + return orderRepository.findByCriteria( + uuid, + symbol, + startTime, + endTime, + orderType, + direction, + limit, + offset, + ).map { it.copy(status = OrderStatus.fromCode(it.statusCode)) }.toList() + } + + override suspend fun getOrderHistoryCount( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + orderType: MatchingOrderType?, + direction: OrderDirection? + ): Long { + return orderRepository.countByCriteria( + uuid, + symbol, + startTime, + endTime, + orderType, + direction, + ).awaitFirstOrElse { 0L } + } + + override suspend fun getTradeHistory( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + direction: OrderDirection?, + limit: Int?, + offset: Int?, + ): List? { + return tradeRepository.findByCriteria( + uuid, + symbol, + startTime, + endTime, + direction, + limit, + offset + ).collectList().awaitSingleOrNull() + } + + override suspend fun getTradeHistoryCount( + uuid: String?, + symbol: String?, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + direction: OrderDirection? + ): Long { + return tradeRepository.countByCriteria( + uuid, + symbol, + startTime, + endTime, + direction, + ).awaitFirst() } } \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/CandleInfoData.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/CandleInfoData.kt index f8c106add..d86457ade 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/CandleInfoData.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/CandleInfoData.kt @@ -1,6 +1,5 @@ package co.nilin.opex.market.ports.postgres.model -import org.springframework.data.relational.core.mapping.Column import java.math.BigDecimal import java.time.LocalDateTime diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/LastPrice.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/LastPrice.kt index 5e9799e9d..ef43d7d9c 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/LastPrice.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/LastPrice.kt @@ -2,4 +2,4 @@ package co.nilin.opex.market.ports.postgres.model import java.math.BigDecimal -data class LastPrice(val symbol:String, val matchedPrice: BigDecimal) \ No newline at end of file +data class LastPrice(val symbol: String, val matchedPrice: BigDecimal) \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/PriceTimeData.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/PriceTimeData.kt new file mode 100644 index 000000000..875e3d76e --- /dev/null +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/PriceTimeData.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.market.ports.postgres.model + +import java.math.BigDecimal +import java.time.LocalDateTime + +data class PriceTimeData( + val closeTime: LocalDateTime, + val closePrice: BigDecimal?, +) \ No newline at end of file diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/TradeModel.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/TradeModel.kt index e434f1c43..7c6bccaa9 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/TradeModel.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/model/TradeModel.kt @@ -1,7 +1,6 @@ package co.nilin.opex.market.ports.postgres.model import org.springframework.data.annotation.Id -import org.springframework.data.relational.core.mapping.Column import org.springframework.data.relational.core.mapping.Table import java.math.BigDecimal import java.time.LocalDateTime diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/Convertor.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/Convertor.kt index ddb42a1b9..0cb69e71d 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/Convertor.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/Convertor.kt @@ -2,20 +2,21 @@ package co.nilin.opex.market.ports.postgres.util import co.nilin.opex.market.core.inout.Transaction import co.nilin.opex.market.core.inout.TransactionDto -import java.math.BigDecimal import java.time.ZoneId import java.util.* - fun Transaction.toDto(): TransactionDto { - return TransactionDto(createDate = Date.from(createDate.atZone(ZoneId.systemDefault()).toInstant()), - volume, - transactionPrice, - matchedPrice, - side, - symbol, - fee, - user) +fun Transaction.toDto(): TransactionDto { + return TransactionDto( + createDate = Date.from(createDate.atZone(ZoneId.systemDefault()).toInstant()), + volume, + transactionPrice, + matchedPrice, + side, + symbol, + fee, + user + ) - } +} diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/RedisCacheHelper.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/RedisCacheHelper.kt index 31bac2103..e74096417 100644 --- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/RedisCacheHelper.kt +++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/util/RedisCacheHelper.kt @@ -24,8 +24,6 @@ class RedisCacheHelper(private val redisTemplate: RedisTemplate) { } fun putList(key: String, values: List, expireAt: DynamicInterval? = null) { - // Why the fuck this doesn't work? - // listOps.rightPushAll(key, values) try { values.forEach { listOps.rightPush(key, it) } expireAt?.let { redisTemplate.expireAt(key, it.dateInFuture()) } diff --git a/market/market-ports/market-persister-postgres/src/main/resources/schema.sql b/market/market-ports/market-persister-postgres/src/main/resources/schema.sql index 8d9195525..37cb1ce0c 100644 --- a/market/market-ports/market-persister-postgres/src/main/resources/schema.sql +++ b/market/market-ports/market-persister-postgres/src/main/resources/schema.sql @@ -67,16 +67,6 @@ CREATE TABLE IF NOT EXISTS trades CREATE INDEX IF NOT EXISTS idx_trades_symbol on trades (symbol); CREATE INDEX IF NOT EXISTS idx_trades_create_date on trades (create_date); -CREATE TABLE IF NOT EXISTS currency_rate -( - id SERIAL PRIMARY KEY, - base VARCHAR(25) NOT NULL, - quote VARCHAR(25) NOT NULL, - source VARCHAR(25) NOT NULL, - rate DECIMAL NOT NULL, - UNIQUE (base, quote, source) -); - CREATE OR REPLACE FUNCTION interval_generator( start_ts TIMESTAMP without TIME ZONE, end_ts TIMESTAMP without TIME ZONE, @@ -90,14 +80,8 @@ CREATE OR REPLACE FUNCTION interval_generator( as $$ BEGIN - RETURN QUERY - SELECT (n) start_time, - (n + round_interval) end_time - FROM generate_series( - date_trunc('minute', start_ts), - end_ts, - round_interval - ) n; + RETURN QUERY SELECT (n) start_time, (n + round_interval) end_time + FROM generate_series(date_trunc('minute', start_ts), end_ts, round_interval) n; END; $$ LANGUAGE 'plpgsql'; diff --git a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt index 6b64ebeea..bb25377ae 100644 --- a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt +++ b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt @@ -1,6 +1,9 @@ package co.nilin.opex.market.ports.postgres.impl -import co.nilin.opex.market.core.inout.* +import co.nilin.opex.market.core.inout.MarketTrade +import co.nilin.opex.market.core.inout.Order +import co.nilin.opex.market.core.inout.OrderDirection +import co.nilin.opex.market.core.inout.OrderStatus import co.nilin.opex.market.ports.postgres.dao.OrderRepository import co.nilin.opex.market.ports.postgres.dao.OrderStatusRepository import co.nilin.opex.market.ports.postgres.dao.TradeRepository @@ -10,7 +13,6 @@ import co.nilin.opex.market.ports.postgres.util.RedisCacheHelper import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -57,8 +59,8 @@ class MarketQueryHandlerTest { val orderBookResponses = marketQueryHandler.openBidOrders(VALID.ETH_USDT, 1) assertThat(orderBookResponses).isNotNull - assertThat(orderBookResponses.size).isEqualTo(1) - assertThat(orderBookResponses.first()).isEqualTo(VALID.ORDER_BOOK_RESPONSE) + assertThat(orderBookResponses?.size).isEqualTo(1) + assertThat(orderBookResponses?.first()).isEqualTo(VALID.ORDER_BOOK_RESPONSE) } @Test @@ -111,9 +113,10 @@ class MarketQueryHandlerTest { every { redisCacheHelper.getList(any()) } returns null every { tradeRepository.findBySymbolSortDescendingByCreateDate(VALID.ETH_USDT, 1) - } returns flow { - emit(VALID.TRADE_MODEL) - } + } returns Flux.just(VALID.TRADE_MODEL) + every { + tradeRepository.findRecentMarketTrades(VALID.ETH_USDT, 1) + } returns Flux.just(VALID.MARKET_TRADE) every { orderRepository.findByOuid(VALID.TRADE_MODEL.makerOuid) } returns Mono.just(VALID.MAKER_ORDER_MODEL) @@ -126,8 +129,8 @@ class MarketQueryHandlerTest { val marketTradeResponses = marketQueryHandler.recentTrades(VALID.ETH_USDT, 1) assertThat(marketTradeResponses).isNotNull - assertThat(marketTradeResponses.count()).isEqualTo(1) - assertThat(marketTradeResponses.first()).isEqualTo(VALID.MARKET_TRADE_RESPONSE) + assertThat(marketTradeResponses?.count()).isEqualTo(1) + assertThat(marketTradeResponses?.first()).isEqualTo(VALID.MARKET_TRADE_RESPONSE) } } diff --git a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterTest.kt b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterTest.kt index 48e71a9f1..0a533641a 100644 --- a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterTest.kt +++ b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/OrderPersisterTest.kt @@ -1,10 +1,12 @@ package co.nilin.opex.market.ports.postgres.impl +import co.nilin.opex.market.core.spi.MarketOrderProducer import co.nilin.opex.market.ports.postgres.dao.OpenOrderRepository import co.nilin.opex.market.ports.postgres.dao.OrderRepository import co.nilin.opex.market.ports.postgres.dao.OrderStatusRepository import co.nilin.opex.market.ports.postgres.impl.sample.VALID import co.nilin.opex.market.ports.postgres.util.RedisCacheHelper +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.runBlocking @@ -16,9 +18,16 @@ class OrderPersisterTest { private val orderRepository = mockk() private val orderStatusRepository = mockk() private val openOrderRepository = mockk() + private val marketOrderProducer = mockk() private val redisCacheHelper = mockk() private val orderPersister = - OrderPersisterImpl(orderRepository, orderStatusRepository, openOrderRepository, redisCacheHelper) + OrderPersisterImpl( + orderRepository, + orderStatusRepository, + openOrderRepository, + redisCacheHelper, + marketOrderProducer + ) @Test fun givenOrderRepo_whenSaveRichOrder_thenSuccess(): Unit = runBlocking { @@ -38,6 +47,10 @@ class OrderPersisterTest { openOrderRepository.delete(any()) } returns Mono.empty() every { redisCacheHelper.put(any(), any()) } returns Unit + every { + orderRepository.findByOuid(any()) + } returns Mono.just(VALID.MAKER_ORDER_MODEL) + coEvery { marketOrderProducer.openOrderUpdate(any(), any()) } returns Unit assertThatNoException().isThrownBy { runBlocking { orderPersister.save(VALID.RICH_ORDER) } } } @@ -56,6 +69,10 @@ class OrderPersisterTest { every { openOrderRepository.delete(any()) } returns Mono.empty() + every { + orderRepository.findByOuid(any()) + } returns Mono.just(VALID.MAKER_ORDER_MODEL) + coEvery { marketOrderProducer.openOrderUpdate(any(), any()) } returns Unit assertThatNoException().isThrownBy { runBlocking { orderPersister.update(VALID.RICH_ORDER_UPDATE) } } } diff --git a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterTest.kt b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterTest.kt index 807fc3261..2a2f8f531 100644 --- a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterTest.kt +++ b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/TradePersisterTest.kt @@ -1,6 +1,5 @@ package co.nilin.opex.market.ports.postgres.impl -import co.nilin.opex.market.ports.postgres.dao.CurrencyRateRepository import co.nilin.opex.market.ports.postgres.dao.TradeRepository import co.nilin.opex.market.ports.postgres.impl.sample.VALID import co.nilin.opex.market.ports.postgres.util.RedisCacheHelper @@ -14,15 +13,13 @@ import reactor.core.publisher.Mono class TradePersisterTest { private val tradeRepository = mockk() - private val currencyRateRepository = mockk() private val cacheHelper = mockk() - private val tradePersister = TradePersisterImpl(tradeRepository, currencyRateRepository, cacheHelper) + private val tradePersister = + TradePersisterImpl(tradeRepository, cacheHelper) @Test fun givenTradeRepo_whenSaveRichTrade_thenSuccess(): Unit = runBlocking { every { tradeRepository.save(any()) } returns Mono.just(VALID.TRADE_MODEL) - every { currencyRateRepository.createOrUpdate(any(), any(), any(), any()) } returns Mono.empty() - assertThatNoException().isThrownBy { runBlocking { tradePersister.save(VALID.RICH_TRADE) } } } } diff --git a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerTest.kt b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerTest.kt index c30d04944..415c75fe4 100644 --- a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerTest.kt +++ b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/UserQueryHandlerTest.kt @@ -7,12 +7,11 @@ import co.nilin.opex.market.ports.postgres.dao.TradeRepository import co.nilin.opex.market.ports.postgres.impl.sample.VALID import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.count -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import reactor.core.publisher.Flux import reactor.core.publisher.Mono class UserQueryHandlerTest { @@ -49,7 +48,7 @@ class UserQueryHandlerTest { @Test fun givenOrderAndTrade_whenAllTrades_thenTradeResponseList(): Unit = runBlocking { every { - tradeRepository.findByUuidAndSymbolAndTimeBetweenAndTradeIdGreaterThan( + tradeRepository.findTradesWithUserContext( VALID.PRINCIPAL.name, VALID.TRADE_REQUEST.symbol, 1, @@ -57,9 +56,7 @@ class UserQueryHandlerTest { VALID.TRADE_REQUEST.endTime, VALID.TRADE_REQUEST.limit ) - } returns flow { - emit(VALID.TRADE_MODEL) - } + } returns Flux.just(VALID.TRADE_USER_CONTEXT) every { orderRepository.findByOuid(VALID.TRADE_MODEL.makerOuid) } returns Mono.just(VALID.MAKER_ORDER_MODEL) @@ -70,7 +67,7 @@ class UserQueryHandlerTest { val tradeResponses = userQueryHandler.allTrades(VALID.PRINCIPAL.name, VALID.TRADE_REQUEST) assertThat(tradeResponses).isNotNull - assertThat(tradeResponses.count()).isEqualTo(1) + assertThat(tradeResponses?.count()).isEqualTo(1) } @Test diff --git a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/sample/Samples.kt b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/sample/Samples.kt index c5538f441..0eca09735 100644 --- a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/sample/Samples.kt +++ b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/sample/Samples.kt @@ -4,11 +4,12 @@ import co.nilin.opex.market.core.event.RichOrder import co.nilin.opex.market.core.event.RichOrderUpdate import co.nilin.opex.market.core.event.RichTrade import co.nilin.opex.market.core.inout.* +import co.nilin.opex.market.ports.postgres.data.MarketTradeProjection +import co.nilin.opex.market.ports.postgres.data.TradeUserContextProjection import co.nilin.opex.market.ports.postgres.model.LastPrice import co.nilin.opex.market.ports.postgres.model.OrderModel import co.nilin.opex.market.ports.postgres.model.OrderStatusModel import co.nilin.opex.market.ports.postgres.model.TradeModel -import co.nilin.opex.market.ports.postgres.util.isWorking import java.math.BigDecimal import java.security.Principal import java.time.LocalDateTime @@ -109,6 +110,37 @@ object VALID { UPDATE_DATE ) + + val TRADE_USER_CONTEXT = TradeUserContextProjection( + ETH_USDT, + "ETH", + "USDT", + 1, + BigDecimal.valueOf(100000), + BigDecimal.valueOf(0.001), // Minimum of orders quantities + BigDecimal.valueOf(100).stripTrailingZeros(), + UPDATE_DATE, + false, + MAKER_ORDER_MODEL.ouid, + BigDecimal.valueOf(0.001), + "USDT", + true, + true + ) + + val MARKET_TRADE = MarketTradeProjection( + ETH_USDT, + "ETH", + "USDT", + 1, + BigDecimal.valueOf(100000), + BigDecimal.valueOf(0.001), // Minimum of orders quantities + BigDecimal.valueOf(100).stripTrailingZeros(), + UPDATE_DATE, + false, + + ) + val LAST_PRICE_MODEL = LastPrice("ETH_USDT", BigDecimal.valueOf(100000)) val AGGREGATED_ORDER_PRICE_MODEL = AggregatedOrderPriceModel( diff --git a/market/pom.xml b/market/pom.xml index e64de9679..5013b6fdd 100644 --- a/market/pom.xml +++ b/market/pom.xml @@ -19,6 +19,7 @@ market-app market-core market-ports/market-eventlistener-kafka + market-ports/market-eventproducer-kafka market-ports/market-persister-postgres @@ -50,6 +51,11 @@ market-eventlistener-kafka ${project.version} + + co.nilin.opex.market.ports.kafka.producer + market-eventproducer-kafka + 1.0.1-beta.7 + co.nilin.opex.market.ports.binance market-binance-rest @@ -70,11 +76,6 @@ interceptors ${interceptor.version} - - co.nilin.opex.utility - preferences - ${preferences.version} - org.springframework.cloud spring-cloud-dependencies diff --git a/matching-engine/matching-engine-app/Dockerfile b/matching-engine/matching-engine-app/Dockerfile index 9c566c1b2..29f0c2703 100644 --- a/matching-engine/matching-engine-app/Dockerfile +++ b/matching-engine/matching-engine-app/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11 +FROM eclipse-temurin:17-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] diff --git a/matching-engine/matching-engine-app/pom.xml b/matching-engine/matching-engine-app/pom.xml index 1ca63381a..7bd61ad6e 100644 --- a/matching-engine/matching-engine-app/pom.xml +++ b/matching-engine/matching-engine-app/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -47,10 +47,6 @@ org.springframework.boot spring-boot-starter-actuator - - co.nilin.opex.utility - preferences - io.micrometer micrometer-registry-prometheus diff --git a/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/ExchangeEventHandler.kt b/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/ExchangeEventHandler.kt index 251d7c889..e967cdebc 100644 --- a/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/ExchangeEventHandler.kt +++ b/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/ExchangeEventHandler.kt @@ -3,15 +3,19 @@ package co.nilin.opex.matching.engine.app.bl import co.nilin.opex.matching.engine.app.config.AppSchedulers import co.nilin.opex.matching.engine.core.eventh.EventDispatcher import co.nilin.opex.matching.engine.core.eventh.events.* +import co.nilin.opex.matching.engine.core.inout.OrderBookUpdateEvent import co.nilin.opex.matching.engine.core.spi.OrderBookPersister import co.nilin.opex.matching.engine.ports.kafka.submitter.service.EventsSubmitter +import co.nilin.opex.matching.engine.ports.kafka.submitter.service.OrderBookUpdateSubmitter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.springframework.stereotype.Component @Component class ExchangeEventHandler( - eventsSubmitter: EventsSubmitter, orderBookPersister: OrderBookPersister + private val eventsSubmitter: EventsSubmitter, + private val orderBookPersister: OrderBookPersister, + private val orderBookSubmitter: OrderBookUpdateSubmitter ) { fun register() { EventDispatcher.register(CreateOrderEvent::class.java, handler) @@ -32,6 +36,12 @@ class ExchangeEventHandler( val localHandler: (OrderBookPublishedEvent) -> Unit = { CoroutineScope(AppSchedulers.generalExecutor).launch { orderBookPersister.storeLastState(it.persistentOrderBook) + val orderBookUpdate = OrderBookUpdateEvent( + it.persistentOrderBook.pair, + System.currentTimeMillis(), + it.persistentOrderBook.orders ?: emptyList() + ) + orderBookSubmitter.submit(orderBookUpdate) } } diff --git a/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/OrderBooks.kt b/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/OrderBooks.kt index af298bbf8..95f0fc30a 100644 --- a/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/OrderBooks.kt +++ b/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/bl/OrderBooks.kt @@ -6,12 +6,13 @@ import co.nilin.opex.matching.engine.core.model.Pair import co.nilin.opex.matching.engine.core.model.PersistentOrderBook object OrderBooks { + private val orderBooks = mutableMapOf() fun createOrderBook(pair: String) { println("Going to add order book:" + pair + ", current order books#" + orderBooks.size) if (orderBooks.containsKey(pair)) - throw IllegalArgumentException("${pair} has an order book right now!") + throw IllegalArgumentException("$pair has an order book right now!") val pairs = pair.split("_") orderBooks[pair] = OrderBookFactory.createOrderBook(Pair(pairs[0], pairs[1])) println("order book:" + pair + " added, current order books#" + orderBooks.size) diff --git a/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/config/InitializeService.kt b/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/config/InitializeService.kt index 02c3beb49..7caf5f262 100644 --- a/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/config/InitializeService.kt +++ b/matching-engine/matching-engine-app/src/main/kotlin/co/nilin/opex/matching/engine/app/config/InitializeService.kt @@ -1,7 +1,5 @@ package co.nilin.opex.matching.engine.app.config -import co.nilin.opex.utility.preferences.Preferences -import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -9,12 +7,6 @@ import org.springframework.context.annotation.Configuration @Configuration class InitializeService { - @Autowired - private lateinit var preferences: Preferences - - /*@Bean("symbols") - fun getSymbols(): List = preferences.markets.map { it.pair ?: "${it.leftSide}_${it.rightSide}" }*/ - @Bean("symbols") fun getSymbols(@Value("\${app.symbols}") symbols: String): List { return symbols.split(",").map { it.trim() }.map { it.uppercase() } diff --git a/matching-engine/matching-engine-app/src/main/resources/application.yml b/matching-engine/matching-engine-app/src/main/resources/application.yml index 4b384fdef..2e1778e5c 100644 --- a/matching-engine/matching-engine-app/src/main/resources/application.yml +++ b/matching-engine/matching-engine-app/src/main/resources/application.yml @@ -16,7 +16,7 @@ management: web: base-path: /actuator exposure: - include: ["health", "prometheus", "metrics"] + include: [ "health", "prometheus", "metrics" ] endpoint: health: show-details: when_authorized @@ -26,3 +26,8 @@ management: enabled: true app: symbols: ${SYMBOLS} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} diff --git a/matching-engine/matching-engine-core/pom.xml b/matching-engine/matching-engine-core/pom.xml index 25c222bae..e9c95993c 100644 --- a/matching-engine/matching-engine-core/pom.xml +++ b/matching-engine/matching-engine-core/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/EventDispatcher.kt b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/EventDispatcher.kt index 7d6c4d4f4..cc2d31cfc 100644 --- a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/EventDispatcher.kt +++ b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/EventDispatcher.kt @@ -18,7 +18,6 @@ object EventDispatcher { eventsHandler.getOrPut(type, { LinkedList() }).add(listener) } - fun emit(event: CoreEvent) { var type: Class<*>? = event::class.java while (type != null) { @@ -31,7 +30,6 @@ object EventDispatcher { } } - open class EventListener( val lambda: (T) -> Unit ) { diff --git a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderBookUpdateEvent.kt b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderBookUpdateEvent.kt new file mode 100644 index 000000000..8e615433f --- /dev/null +++ b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderBookUpdateEvent.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.matching.engine.core.inout + +import co.nilin.opex.matching.engine.core.model.Pair +import co.nilin.opex.matching.engine.core.model.PersistentOrder + +data class OrderBookUpdateEvent( + val pair: Pair, + val time: Long, + val orders: List +) \ No newline at end of file diff --git a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderRequestEvent.kt b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderRequestEvent.kt index 3ec793a34..236a74b36 100644 --- a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderRequestEvent.kt +++ b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderRequestEvent.kt @@ -2,4 +2,4 @@ package co.nilin.opex.matching.engine.core.inout import co.nilin.opex.matching.engine.core.model.Pair -abstract class OrderRequestEvent(val ouid:String, val uuid: String, val pair: Pair) \ No newline at end of file +abstract class OrderRequestEvent(val ouid: String, val uuid: String, val pair: Pair) \ No newline at end of file diff --git a/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/pom.xml b/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/pom.xml index 981aaeb33..876f69b94 100644 --- a/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/pom.xml +++ b/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/listener/config/OrderKafkaConfig.kt b/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/listener/config/OrderKafkaConfig.kt index 6262ba1df..ec63df8aa 100644 --- a/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/listener/config/OrderKafkaConfig.kt +++ b/matching-engine/matching-engine-ports/matching-engine-eventlistener-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/listener/config/OrderKafkaConfig.kt @@ -2,7 +2,6 @@ package co.nilin.opex.matching.engine.ports.kafka.listener.config import co.nilin.opex.matching.engine.core.eventh.events.CoreEvent import co.nilin.opex.matching.engine.core.inout.OrderRequestEvent -import co.nilin.opex.matching.engine.core.inout.OrderSubmitRequestEvent import co.nilin.opex.matching.engine.ports.kafka.listener.consumer.EventKafkaListener import co.nilin.opex.matching.engine.ports.kafka.listener.consumer.OrderKafkaListener import org.apache.kafka.clients.consumer.ConsumerConfig diff --git a/matching-engine/matching-engine-ports/matching-engine-snapshots-redis/pom.xml b/matching-engine/matching-engine-ports/matching-engine-snapshots-redis/pom.xml index 042b22c13..1dd1e88ca 100644 --- a/matching-engine/matching-engine-ports/matching-engine-snapshots-redis/pom.xml +++ b/matching-engine/matching-engine-ports/matching-engine-snapshots-redis/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/pom.xml b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/pom.xml index b13662c63..ad9220e32 100644 --- a/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/pom.xml +++ b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/EventsKafkaConfig.kt b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/EventsKafkaConfig.kt index 6fdf44e04..667fceeac 100644 --- a/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/EventsKafkaConfig.kt +++ b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/EventsKafkaConfig.kt @@ -1,8 +1,8 @@ package co.nilin.opex.matching.engine.ports.kafka.submitter.config import co.nilin.opex.matching.engine.core.eventh.events.CoreEvent +import co.nilin.opex.matching.engine.core.inout.OrderBookUpdateEvent import co.nilin.opex.matching.engine.core.inout.OrderRequestEvent -import co.nilin.opex.matching.engine.core.inout.OrderSubmitRequestEvent import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer import org.springframework.beans.factory.annotation.Qualifier @@ -12,6 +12,7 @@ import org.springframework.context.annotation.Configuration import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonDeserializer import org.springframework.kafka.support.serializer.JsonSerializer @Configuration @@ -26,7 +27,9 @@ class EventsKafkaConfig { ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, - ProducerConfig.ACKS_CONFIG to "all" + ProducerConfig.ACKS_CONFIG to "all", + JsonDeserializer.TRUSTED_PACKAGES to "co.nilin.opex.*", + JsonDeserializer.TYPE_MAPPINGS to "orderBookUpdate:co.nilin.opex.matching.engine.core.inout.OrderBookUpdateEvent" ) } @@ -50,4 +53,9 @@ class EventsKafkaConfig { return KafkaTemplate(producerFactory) } + @Bean("orderBookUpdateTemplate") + fun orderBookUpdateTemplate(@Qualifier("producerConfigs") producerConfigs: Map): KafkaTemplate { + return KafkaTemplate(DefaultKafkaProducerFactory(producerConfigs)) + } + } \ No newline at end of file diff --git a/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/KafkaTopicConfig.kt b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/KafkaTopicConfig.kt index ee8446e29..a11f308fc 100644 --- a/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/KafkaTopicConfig.kt +++ b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/config/KafkaTopicConfig.kt @@ -48,6 +48,13 @@ class KafkaTopicConfig { .build() }) } + + applicationContext.registerBean("topic_orderBookUpdate", NewTopic::class.java, Supplier { + TopicBuilder.name("orderBookUpdate") + .partitions(10) + .replicas(1) + .build() + }) } } diff --git a/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/service/OrderBookUpdateSubmitter.kt b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/service/OrderBookUpdateSubmitter.kt new file mode 100644 index 000000000..e617ddb2b --- /dev/null +++ b/matching-engine/matching-engine-ports/matching-engine-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/engine/ports/kafka/submitter/service/OrderBookUpdateSubmitter.kt @@ -0,0 +1,34 @@ +package co.nilin.opex.matching.engine.ports.kafka.submitter.service + +import co.nilin.opex.matching.engine.core.inout.OrderBookUpdateEvent +import org.slf4j.LoggerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +@Component +class OrderBookUpdateSubmitter(private val kafkaTemplate: KafkaTemplate) { + + private val logger = LoggerFactory.getLogger(OrderBookUpdateSubmitter::class.java) + private var lastSent = 0L + + suspend fun submit(event: OrderBookUpdateEvent): Unit = suspendCoroutine { cont -> + val now = System.currentTimeMillis() + if (now - lastSent < 1000) // send every second not more + return@suspendCoroutine + + kafkaTemplate.send("orderBookUpdate", event).addCallback( + { + logger.info("Orderbook update event sent") + lastSent = System.currentTimeMillis() + cont.resume(Unit) + }, + { + logger.warn("Orderbook update event failed", it) + cont.resumeWithException(it) + } + ) + } +} \ No newline at end of file diff --git a/matching-engine/pom.xml b/matching-engine/pom.xml index 737dabe1d..010d84c41 100644 --- a/matching-engine/pom.xml +++ b/matching-engine/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -62,11 +62,6 @@ interceptors ${interceptor.version} - - co.nilin.opex.utility - preferences - ${preferences.version} - diff --git a/matching-gateway/matching-gateway-app/Dockerfile b/matching-gateway/matching-gateway-app/Dockerfile index 9c566c1b2..29f0c2703 100644 --- a/matching-gateway/matching-gateway-app/Dockerfile +++ b/matching-gateway/matching-gateway-app/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11 +FROM eclipse-temurin:17-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] diff --git a/matching-gateway/matching-gateway-app/pom.xml b/matching-gateway/matching-gateway-app/pom.xml index 821456c1b..86d029320 100644 --- a/matching-gateway/matching-gateway-app/pom.xml +++ b/matching-gateway/matching-gateway-app/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -39,6 +39,10 @@ co.nilin.opex.matching.gateway.ports.kafka.submitter matching-gateway-submitter-kafka + + co.nilin.opex.matching.gateway.ports.postgres + matching-gateway-persister-postgres + org.springframework.cloud spring-cloud-starter-consul-all @@ -55,6 +59,10 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server + + org.springframework.cloud + spring-cloud-starter-vault-config + org.bouncycastle bcprov-jdk15on @@ -69,6 +77,10 @@ co.nilin.opex.utility error-handler + + co.nilin.opex.utility + interceptors + io.mockk mockk diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt index ed4019789..5132c5c21 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt @@ -1,9 +1,15 @@ package co.nilin.opex.matching.gateway.app.config +import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import co.nilin.opex.matching.gateway.app.utils.AudienceValidator +import co.nilin.opex.matching.gateway.app.utils.hasRole import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean +import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +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.web.server.SecurityWebFilterChain @@ -13,8 +19,9 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String - + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { http.csrf().disable() @@ -23,19 +30,37 @@ class SecurityConfig(private val webClient: WebClient) { .pathMatchers("/swagger-ui/**").permitAll() .pathMatchers("/swagger-resources/**").permitAll() .pathMatchers("/v2/api-docs").permitAll() - .pathMatchers("/**").hasAuthority("SCOPE_trust") + .pathMatchers(HttpMethod.GET, "/pair-setting/**").permitAll() + .pathMatchers(HttpMethod.PUT, "/pair-setting/**").hasAuthority("ROLE_admin") .anyExchange().authenticated() .and() .oauth2ResourceServer() - .jwt() + .jwt { it.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } return http.build() } + @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) - .webClient(webClient) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } } diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/WebClientConfig.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/WebClientConfig.kt index 0fd266d83..9a7e82138 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/WebClientConfig.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/WebClientConfig.kt @@ -5,7 +5,6 @@ import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalanc import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.web.reactive.function.client.WebClient import org.zalando.logbook.Logbook import org.zalando.logbook.netty.LogbookClientHandler diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/controller/ControllerExceptionHandler.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/controller/ControllerExceptionHandler.kt deleted file mode 100644 index 4e61b272c..000000000 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/controller/ControllerExceptionHandler.kt +++ /dev/null @@ -1,98 +0,0 @@ -package co.nilin.opex.matching.gateway.app.controller - -import co.nilin.opex.matching.gateway.app.exception.NotAllowedToSubmitOrderException -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.databind.ObjectMapper -import org.slf4j.LoggerFactory -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.reactive.function.client.WebClientResponseException -import java.nio.charset.StandardCharsets -import java.util.* - -//@RestControllerAdvice -class ControllerExceptionHandler { - - data class ErrorResponse( - val timestamp: Date, val status: Int, val error: String, val message: String - ) - - val logger = LoggerFactory.getLogger(ControllerExceptionHandler::class.java) - - val objectMapper: ObjectMapper = ObjectMapper() - - @ExceptionHandler(NotAllowedToSubmitOrderException::class) - fun handle(ex: NotAllowedToSubmitOrderException): ResponseEntity { - logger.error("Trace Error {}", ex) - val ret = ResponseEntity.status(500).body( - ErrorResponse( - Date(), -1, ex::class.qualifiedName ?: "", ex.message ?: "" - ) - ) - logger.debug("return error response:{}", ret) - return ret - } - - @JsonIgnoreProperties(ignoreUnknown = true) - class WebClientErrorResponse { - constructor() { - - } - - constructor(timestamp: Date?, path: String?, status: Int?, error: String?, message: String?) { - this.timestamp = timestamp - this.path = path - this.status = status - this.error = error - this.message = message - } - - var timestamp: Date? = null - var path: String? = null - var status: Int? = null - var error: String? = null - var message: String? = null - } - - @ExceptionHandler(WebClientResponseException::class) - fun handle(ex: WebClientResponseException): ResponseEntity { - logger.error("Trace Error {}", ex) - try { - val body = objectMapper.readValue( - ex.responseBodyAsByteArray.toString(StandardCharsets.UTF_8), - WebClientErrorResponse::class.java - ) - val ret = ResponseEntity.status(body.status ?: ex.rawStatusCode).body( - ErrorResponse( - Date(), - body.status ?: ex.rawStatusCode, - body.error ?: ex::class.qualifiedName ?: "", - body.message ?: "Internal Server Error" - ) - ) - logger.debug("return error response:{}", ret) - return ret - } catch (je: Exception) { - logger.error("Trace Error {}", je) - val ret = ResponseEntity.status(ex.statusCode).body( - ErrorResponse( - Date(), ex.rawStatusCode, ex::class.qualifiedName ?: "", "Internal Server Error" - ) - ) - logger.debug("return error response:{}", ret) - return ret - } - } - - @ExceptionHandler(Throwable::class) - fun handle(ex: Throwable): ResponseEntity { - logger.error("Trace Error {}", ex) - val ret = ResponseEntity.status(500).body( - ErrorResponse( - Date(), 500, ex::class.qualifiedName ?: "", "Internal Server Error" - ) - ) - logger.debug("return error response:{}", ret) - return ret - } -} diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/controller/PairSettingController.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/controller/PairSettingController.kt new file mode 100644 index 000000000..1a578a945 --- /dev/null +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/controller/PairSettingController.kt @@ -0,0 +1,26 @@ +package co.nilin.opex.matching.gateway.app.controller + +import co.nilin.opex.matching.gateway.ports.postgres.dto.PairSetting +import co.nilin.opex.matching.gateway.ports.postgres.service.PairSettingService +import org.springframework.web.bind.annotation.* +import java.math.BigDecimal + +@RestController +@RequestMapping("/pair-setting") +class PairSettingController(private val pairSettingService: PairSettingService) { + + @GetMapping("/{pair}") + suspend fun getPairSetting(@PathVariable pair: String): PairSetting { + return pairSettingService.load(pair) + } + + @GetMapping + suspend fun getPairSettings(): List { + return pairSettingService.loadAll() + } + + @PutMapping + suspend fun updatePairSetting(@RequestBody pairSetting: PairSetting): PairSetting { + return pairSettingService.update(pairSetting) + } +} \ No newline at end of file diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/proxy/PairConfigLoaderImpl.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/proxy/PairConfigLoaderImpl.kt index 27379e3c4..3086011ef 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/proxy/PairConfigLoaderImpl.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/proxy/PairConfigLoaderImpl.kt @@ -1,14 +1,29 @@ package co.nilin.opex.matching.gateway.app.proxy +import co.nilin.opex.common.utils.CacheManager import co.nilin.opex.matching.engine.core.model.OrderDirection import co.nilin.opex.matching.gateway.app.inout.PairConfig import co.nilin.opex.matching.gateway.app.spi.AccountantApiProxy import co.nilin.opex.matching.gateway.app.spi.PairConfigLoader +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit @Service -class PairConfigLoaderImpl(private val accountantApiProxy: AccountantApiProxy) : PairConfigLoader { +class PairConfigLoaderImpl( + private val accountantApiProxy: AccountantApiProxy, + @Qualifier("appCacheManager") private val cacheManager: CacheManager +) : PairConfigLoader { override suspend fun load(pair: String, direction: OrderDirection): PairConfig { - return accountantApiProxy.fetchPairConfig(pair, direction) + return cacheManager.get("pair-config:$pair-$direction") + ?: accountantApiProxy.fetchPairConfig(pair, direction) + .also { + cacheManager.put( + "pair-config:$pair-$direction", + it, + 5, TimeUnit.MINUTES + ) + + } } } \ No newline at end of file diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt index 79f999546..e866202e9 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt @@ -12,15 +12,18 @@ import co.nilin.opex.matching.gateway.ports.kafka.submitter.inout.OrderSubmitReq import co.nilin.opex.matching.gateway.ports.kafka.submitter.inout.OrderSubmitResult import co.nilin.opex.matching.gateway.ports.kafka.submitter.service.KafkaHealthIndicator import co.nilin.opex.matching.gateway.ports.kafka.submitter.service.OrderRequestEventSubmitter +import co.nilin.opex.matching.gateway.ports.postgres.service.PairSettingService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.math.BigDecimal +import java.math.RoundingMode @Service class OrderService( val accountantApiProxy: AccountantApiProxy, val orderRequestEventSubmitter: OrderRequestEventSubmitter, val pairConfigLoader: PairConfigLoader, + val pairSettingService: PairSettingService, private val kafkaHealthIndicator: KafkaHealthIndicator, ) { @@ -28,13 +31,25 @@ class OrderService( suspend fun submitNewOrder(createOrderRequest: CreateOrderRequest): OrderSubmitResult { require(createOrderRequest.price >= BigDecimal.ZERO) + + val pairSetting = pairSettingService.load(createOrderRequest.pair) + if (!pairSetting.isAvailable) + throw OpexError.PairIsNotAvailable.exception() + if (!pairSetting.orderTypes.split(",").contains(createOrderRequest.orderType.name)) { + throw OpexError.InvalidOrderType.exception() + } + if ((createOrderRequest.quantity * createOrderRequest.price) > pairSetting.maxOrder || + (createOrderRequest.quantity * createOrderRequest.price) < pairSetting.minOrder) { + throw OpexError.InvalidQuantity.exception() + } + + val symbolSides = createOrderRequest.pair.split("_") val symbol = if (createOrderRequest.direction == OrderDirection.ASK) symbolSides[0] else symbolSides[1] - //TODO cache val pairConfig = pairConfigLoader.load(createOrderRequest.pair, createOrderRequest.direction) val canCreateOrder = runCatching { diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/AudienceValidator.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..6ce28244d --- /dev/null +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.matching.gateway.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/Extensions.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/Extensions.kt new file mode 100644 index 000000000..f78d4fc5a --- /dev/null +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/Extensions.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.matching.gateway.app.utils + +import com.nimbusds.jose.shaded.json.JSONArray +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.jwt.Jwt + +fun ServerHttpSecurity.AuthorizeExchangeSpec.Access.hasRole( + authority: String, + role: String +): ServerHttpSecurity.AuthorizeExchangeSpec = access { mono, _ -> + mono.map { auth -> + val hasAuthority = auth.authorities.any { it.authority == authority } + val hasRole = ((auth.principal as Jwt).claims["roles"] as JSONArray?)?.contains(role) == true + AuthorizationDecision(hasAuthority && hasRole) + } +} + +fun ServerHttpSecurity.AuthorizeExchangeSpec.Access.hasRoleAndLevel( + role: String? = null, + level: String? = null +): ServerHttpSecurity.AuthorizeExchangeSpec = access { mono, _ -> + mono.map { auth -> + val hasLevel = level?.let { ((auth.principal as Jwt).claims["level"] as String?)?.equals(level) == true } + ?: true + val hasRole = ((auth.principal as Jwt).claims["roles"] as JSONArray?)?.contains(role) == true + AuthorizationDecision(hasLevel && hasRole) + } +} diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/VaultUserIdMechanism.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/VaultUserIdMechanism.kt new file mode 100644 index 000000000..969895854 --- /dev/null +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/VaultUserIdMechanism.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.matching.gateway.app.utils + +import org.springframework.vault.authentication.AppIdUserIdMechanism + +class VaultUserIdMechanism() : AppIdUserIdMechanism { + override fun createUserId(): String { + return System.getenv("BACKEND_USER") + } +} \ No newline at end of file diff --git a/matching-gateway/matching-gateway-app/src/main/resources/application.yml b/matching-gateway/matching-gateway-app/src/main/resources/application.yml index 15e19d34e..860504b23 100644 --- a/matching-gateway/matching-gateway-app/src/main/resources/application.yml +++ b/matching-gateway/matching-gateway-app/src/main/resources/application.yml @@ -9,9 +9,33 @@ spring: bootstrap-servers: ${KAFKA_IP_PORT:localhost:9092} consumer: group-id: gateway + r2dbc: + url: r2dbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} + initialization-mode: always + pool: + enabled: true + initial-size: 5 + max-size: 20 + max-idle-time: 60s + validation-query: SELECT 1 cloud: bootstrap: enabled: true + vault: + host: ${VAULT_HOST} + port: 8200 + scheme: http + authentication: APPID + app-id: + user-id: co.nilin.opex.matching.gateway.app.utils.VaultUserIdMechanism + fail-fast: true + kv: + enabled: true + backend: secret + profile-separator: '/' + application-name: ${spring.application.name} consul: host: ${CONSUL_HOST:localhost} port: 8500 @@ -25,7 +49,7 @@ management: web: base-path: /actuator exposure: - include: ["health", "prometheus", "metrics"] + include: [ "health", "prometheus", "metrics" ] endpoint: health: show-details: when_authorized @@ -64,9 +88,18 @@ logging: co.nilin: INFO org.zalando.logbook: TRACE app: + symbols: ${SYMBOLS} accountant: url: lb://opex-accountant auth: - cert-url: lb://opex-auth/auth/realms/opex/protocol/openid-connect/certs + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} swagger: authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token} + + diff --git a/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/OrderServiceTest.kt b/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/OrderServiceTest.kt index 306586dff..176393526 100644 --- a/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/OrderServiceTest.kt +++ b/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/OrderServiceTest.kt @@ -5,10 +5,13 @@ import co.nilin.opex.matching.gateway.app.service.sample.VALID import co.nilin.opex.matching.gateway.app.spi.AccountantApiProxy import co.nilin.opex.matching.gateway.app.spi.PairConfigLoader import co.nilin.opex.matching.gateway.ports.kafka.submitter.inout.OrderSubmitResult -import co.nilin.opex.matching.gateway.ports.kafka.submitter.service.EventSubmitter import co.nilin.opex.matching.gateway.ports.kafka.submitter.service.KafkaHealthIndicator import co.nilin.opex.matching.gateway.ports.kafka.submitter.service.OrderRequestEventSubmitter -import io.mockk.* +import co.nilin.opex.matching.gateway.ports.postgres.service.PairSettingService +import io.mockk.MockKException +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -21,20 +24,28 @@ private class OrderServiceTest { private val eventSubmitter: OrderRequestEventSubmitter = mockk() private val pairConfigLoader: PairConfigLoader = mockk() private val kafkaHealthIndicator: KafkaHealthIndicator = mockk() + private val pairSettingService: PairSettingService = mockk() + private val orderService: OrderService = OrderService( accountantApiProxy, orderRequestEventSubmitter, pairConfigLoader, - kafkaHealthIndicator + pairSettingService, + kafkaHealthIndicator, ) private fun stubASK() { + coEvery { + pairSettingService.load(VALID.ETH_USDT) + } returns VALID.PAIR_SETTING + coEvery { pairConfigLoader.load( VALID.ETH_USDT, OrderDirection.ASK ) } returns VALID.PAIR_CONFIG + coEvery { accountantApiProxy.canCreateOrder( VALID.CREATE_ORDER_REQUEST_ASK.uuid!!, @@ -42,15 +53,20 @@ private class OrderServiceTest { VALID.CREATE_ORDER_REQUEST_ASK.quantity ) } returns true + coEvery { orderRequestEventSubmitter.submit(any()) } returns OrderSubmitResult(null) + coEvery { kafkaHealthIndicator.isHealthy } returns true } private fun stubBID() { + coEvery { + pairSettingService.load(VALID.ETH_USDT) + } returns VALID.PAIR_SETTING coEvery { pairConfigLoader.load( VALID.ETH_USDT, @@ -84,11 +100,13 @@ private class OrderServiceTest { @Test fun givenPair_whenSubmitNewOrderByInvalidSymbol_thenThrow(): Unit = runBlocking { stubASK() - clearMocks(pairConfigLoader) + clearMocks(pairConfigLoader, pairSettingService) coEvery { pairConfigLoader.load("BTC_ETH", OrderDirection.ASK) } throws Exception() - + coEvery { + pairSettingService.load("BTC_ETH") + } throws Exception() assertThatThrownBy { runBlocking { orderService.submitNewOrder(VALID.CREATE_ORDER_REQUEST_ASK.copy(pair = "BTC_ETH")) @@ -130,11 +148,13 @@ private class OrderServiceTest { @Test fun givenPair_whenSubmitNewOrderByBIDAndInvalidSymbol_thenThrow(): Unit = runBlocking { stubBID() - clearMocks(pairConfigLoader) + clearMocks(pairConfigLoader, pairSettingService) coEvery { pairConfigLoader.load("BTC_USDT", OrderDirection.BID) } throws Exception() - + coEvery { + pairSettingService.load("BTC_USDT") + } throws Exception() assertThatThrownBy { runBlocking { orderService.submitNewOrder(VALID.CREATE_ORDER_REQUEST_BID.copy(pair = "BTC_USDT")) diff --git a/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt b/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt index db4aaba58..ea9f1ab47 100644 --- a/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt +++ b/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt @@ -6,6 +6,7 @@ import co.nilin.opex.matching.engine.core.model.OrderType import co.nilin.opex.matching.gateway.app.inout.CancelOrderRequest import co.nilin.opex.matching.gateway.app.inout.CreateOrderRequest import co.nilin.opex.matching.gateway.app.inout.PairConfig +import co.nilin.opex.matching.gateway.ports.postgres.dto.PairSetting import java.math.BigDecimal object VALID { @@ -23,6 +24,8 @@ object VALID { val PAIR_CONFIG = PairConfig(ETH_USDT, ETH, USDT, BigDecimal.valueOf(0.01), BigDecimal.valueOf(0.0001)) + val PAIR_SETTING = PairSetting(ETH_USDT, true, 0.0000001.toBigDecimal(), 100.toBigDecimal(), "LIMIT_ORDER,MARKET_ORDER", null) + val CREATE_ORDER_REQUEST_ASK = CreateOrderRequest( UUID, ETH_USDT, diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/pom.xml b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/pom.xml new file mode 100644 index 000000000..5b4b55151 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + + co.nilin.opex.matching.gateway + matching-gateway + 1.0.1-beta.7 + ../../pom.xml + + + co.nilin.opex.matching.gateway.ports.postgres + matching-gateway-persister-postgres + matching-gateway-persister-postgres + Persist items of Opex Matching gateway on Postgres + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + io.r2dbc + r2dbc-pool + + + io.r2dbc + r2dbc-spi + + + + + org.postgresql + r2dbc-postgresql + runtime + + + io.r2dbc + r2dbc-pool + + + io.r2dbc + r2dbc-spi + + + + + io.r2dbc + r2dbc-pool + 1.0.1.RELEASE + + + io.r2dbc + r2dbc-spi + 1.0.0.RELEASE + + + org.postgresql + postgresql + runtime + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + com.google.code.gson + gson + + + co.nilin.opex.utility + error-handler + + + io.projectreactor + reactor-test + test + + + diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/config/PostgresConfig.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/config/PostgresConfig.kt new file mode 100644 index 000000000..c364136b8 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/config/PostgresConfig.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.matching.gateway.ports.postgres.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import org.springframework.core.io.Resource +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories +import org.springframework.r2dbc.core.DatabaseClient + +@Configuration +@EnableR2dbcRepositories(basePackages = ["co.nilin.opex"]) +class PostgresConfig( + db: DatabaseClient, + @Value("classpath:schema.sql") private val schemaResource: Resource +) { + init { + val schemaReader = schemaResource.inputStream.reader() + val schema = schemaReader.readText().trim() + schemaReader.close() + val initDb = db.sql { schema } + initDb.then().block() + } +} diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/dao/PairSettingRepository.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/dao/PairSettingRepository.kt new file mode 100644 index 000000000..12fcff723 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/dao/PairSettingRepository.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.matching.gateway.ports.postgres.dao + +import co.nilin.opex.matching.gateway.ports.postgres.model.PairSettingModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono +import java.math.BigDecimal + +@Repository +interface PairSettingRepository : ReactiveCrudRepository { + fun findByPair(pair: String): Mono + + @Query("insert into pair_setting(pair,is_available,min_order,max_order,order_types) values(:pair,:isAvailable,:minOrder,:maxOrder,:orderTypes) ") + fun insert(pair: String, isAvailable: Boolean , minOrder : BigDecimal, maxOrder : BigDecimal,orderTypes : String): Mono +} diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/dto/PairSetting.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/dto/PairSetting.kt new file mode 100644 index 000000000..1fd568629 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/dto/PairSetting.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.matching.gateway.ports.postgres.dto + +import java.math.BigDecimal +import java.time.LocalDateTime + +class PairSetting( + val pair: String, + val isAvailable: Boolean, + val minOrder : BigDecimal, + val maxOrder : BigDecimal, + val orderTypes : String, + val updateDate: LocalDateTime? = null, +) \ No newline at end of file diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/impl/PairSettingServiceImpl.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/impl/PairSettingServiceImpl.kt new file mode 100644 index 000000000..288ef97c9 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/impl/PairSettingServiceImpl.kt @@ -0,0 +1,64 @@ +package co.nilin.opex.matching.gateway.ports.postgres.impl + +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.CacheManager +import co.nilin.opex.matching.gateway.ports.postgres.dao.PairSettingRepository +import co.nilin.opex.matching.gateway.ports.postgres.dto.PairSetting +import co.nilin.opex.matching.gateway.ports.postgres.service.PairSettingService +import co.nilin.opex.matching.gateway.ports.postgres.util.toPairSetting +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +@Service +class PairSettingServiceImpl( + private val pairSettingRepository: PairSettingRepository, + @Qualifier("appCacheManager") private val cacheManager: CacheManager +) : PairSettingService { + + override suspend fun load(pair: String): PairSetting { + return cacheManager.get("pair-setting:$pair") + ?: pairSettingRepository.findByPair(pair) + .awaitFirstOrNull() + ?.let { + it.toPairSetting().also { + cacheManager.put( + "pair-setting:${it.pair}", + it, + 5, TimeUnit.MINUTES + ) + } + } + ?: throw OpexError.PairNotFound.exception() + } + + override suspend fun loadAll(): List { + return pairSettingRepository.findAll() + .map { it.toPairSetting() } + .collectList() + .awaitFirstOrNull() ?: emptyList() + } + + override suspend fun update(pairSetting: PairSetting): PairSetting { + val pairSetting = + pairSettingRepository.findByPair(pairSetting.pair).awaitFirstOrNull() + ?: throw OpexError.PairNotFound.exception() + pairSetting.apply { + this.isAvailable = pairSetting.isAvailable + this.minOrder = pairSetting.minOrder + this.maxOrder = pairSetting.maxOrder + this.orderTypes = pairSetting.orderTypes + this.updateDate = LocalDateTime.now() + } + return pairSettingRepository.save(pairSetting).awaitFirst().toPairSetting().also { + cacheManager.put( + "pair-setting:${it.pair}", + it, + 5, TimeUnit.MINUTES + ) + } + } +} \ No newline at end of file diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/model/PairSettingModel.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/model/PairSettingModel.kt new file mode 100644 index 000000000..f62832c5d --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/model/PairSettingModel.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.matching.gateway.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal +import java.time.LocalDateTime + +@Table("pair_setting") +data class PairSettingModel( + @Id + val pair: String, + var isAvailable: Boolean, + var minOrder : BigDecimal, + var maxOrder : BigDecimal, + var orderTypes : String, + var updateDate: LocalDateTime? = null, +) \ No newline at end of file diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/service/PairSettingInitializer.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/service/PairSettingInitializer.kt new file mode 100644 index 000000000..8bafa2161 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/service/PairSettingInitializer.kt @@ -0,0 +1,71 @@ +package co.nilin.opex.matching.gateway.ports.postgres.service + +import co.nilin.opex.common.utils.CacheManager +import co.nilin.opex.matching.gateway.ports.postgres.dao.PairSettingRepository +import co.nilin.opex.matching.gateway.ports.postgres.dto.PairSetting +import co.nilin.opex.matching.gateway.ports.postgres.util.toPairSetting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.util.concurrent.TimeUnit +import javax.annotation.PostConstruct + +@Service +class PairSettingInitializer( + private val pairSettingRepository: PairSettingRepository, + @Qualifier("appCacheManager") private val cacheManager: CacheManager, + @Value("\${app.symbols}") + private val symbols: String +) { + + private val logger = LoggerFactory.getLogger(PairSettingInitializer::class.java) + val scope = CoroutineScope(Dispatchers.IO) + + @PostConstruct + fun initialize() { + logger.info( + """ +================================================================================================ + Initialize Pair Settings +================================================================================================ + """ + ) + scope.launch { + try { + symbols.split(",").forEach { pair -> + val existingPair = pairSettingRepository.findByPair(pair).awaitFirstOrNull() + + val pairToCache = existingPair ?: pairSettingRepository.insert( + pair, + false, + BigDecimal.ONE, + BigDecimal.ONE, + "LIMIT_ORDER,MARKET_ORDER" + ).then(pairSettingRepository.findByPair(pair)).awaitFirstOrNull() + .also { if (it == null) logger.warn("Failed to insert pair: $pair") } + ?: return@forEach + + if (existingPair != null) logger.info("Pair already exists: $pair") else logger.info("Added Pair: $pair") + + cacheManager.put(pair, pairToCache.toPairSetting(), 5, TimeUnit.MINUTES) + } + logger.info( + """ +================================================================================================ + Completed Successfully +================================================================================================ + """ + ) + } catch (e: Exception) { + logger.error("Error initializing Pair Settings: ${e.message}") + throw e + } + } + } +} diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/service/PairSettingService.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/service/PairSettingService.kt new file mode 100644 index 000000000..723271184 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/service/PairSettingService.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.matching.gateway.ports.postgres.service + +import co.nilin.opex.matching.gateway.ports.postgres.dto.PairSetting + +interface PairSettingService { + suspend fun load(pair: String): PairSetting + suspend fun loadAll(): List + suspend fun update(pairSetting: PairSetting): PairSetting +} \ No newline at end of file diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/util/Convertor.kt b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/util/Convertor.kt new file mode 100644 index 000000000..c039102d7 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/matching/gateway/ports/postgres/util/Convertor.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.matching.gateway.ports.postgres.util + +import co.nilin.opex.matching.gateway.ports.postgres.dto.PairSetting +import co.nilin.opex.matching.gateway.ports.postgres.model.PairSettingModel + + +fun PairSettingModel.toPairSetting(): PairSetting { + return PairSetting( + pair, + isAvailable, + minOrder, + maxOrder, + orderTypes, + updateDate, + ) +} + diff --git a/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/resources/schema.sql b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/resources/schema.sql new file mode 100644 index 000000000..c8a595b04 --- /dev/null +++ b/matching-gateway/matching-gateway-port/matching-gateway-persister-postgres/src/main/resources/schema.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS pair_setting +( + pair VARCHAR(72) PRIMARY KEY, + is_available BOOLEAN NOT NULL DEFAULT FALSE, + update_date TIMESTAMP +); + +DO +$$ + BEGIN + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'pair_setting' AND column_name = 'min_order') THEN ALTER TABLE pair_setting + ADD COLUMN min_order DECIMAL NOT NULL default 1; + END IF; + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'pair_setting' AND column_name = 'max_order') THEN ALTER TABLE pair_setting + ADD COLUMN max_order DECIMAL NOT NULL default 1; + END IF; + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'pair_setting' AND column_name = 'order_types') THEN ALTER TABLE pair_setting + ADD COLUMN order_types varchar(255) NOT NULL default 'LIMIT_ORDER, MARKET_ORDER' ; + END IF; + END +$$; \ No newline at end of file diff --git a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/pom.xml b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/pom.xml index 9ef385922..bd520bfd6 100644 --- a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/pom.xml +++ b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/config/OrderKafkaConfig.kt b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/config/OrderKafkaConfig.kt index 0e0bf5189..4f1e96623 100644 --- a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/config/OrderKafkaConfig.kt +++ b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/config/OrderKafkaConfig.kt @@ -2,7 +2,6 @@ package co.nilin.opex.matching.gateway.ports.kafka.submitter.config import co.nilin.opex.matching.engine.core.eventh.events.CoreEvent import co.nilin.opex.matching.gateway.ports.kafka.submitter.inout.OrderRequestEvent -import co.nilin.opex.matching.gateway.ports.kafka.submitter.inout.OrderSubmitRequestEvent import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer import org.springframework.beans.factory.annotation.Qualifier diff --git a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderRequestEvent.kt b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderRequestEvent.kt index 462f2f58a..88bb3f7b7 100644 --- a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderRequestEvent.kt +++ b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderRequestEvent.kt @@ -2,4 +2,4 @@ package co.nilin.opex.matching.gateway.ports.kafka.submitter.inout import co.nilin.opex.matching.engine.core.model.Pair -abstract class OrderRequestEvent(val ouid:String, val uuid: String, val pair: Pair) \ No newline at end of file +abstract class OrderRequestEvent(val ouid: String, val uuid: String, val pair: Pair) \ No newline at end of file diff --git a/matching-gateway/pom.xml b/matching-gateway/pom.xml index 3fd2fe323..56e9c1b09 100644 --- a/matching-gateway/pom.xml +++ b/matching-gateway/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -18,6 +18,7 @@ matching-gateway-app matching-gateway-port/matching-gateway-submitter-kafka + matching-gateway-port/matching-gateway-persister-postgres @@ -48,6 +49,11 @@ matching-gateway-submitter-kafka ${project.version} + + co.nilin.opex.matching.gateway.ports.postgres + matching-gateway-persister-postgres + ${project.version} + co.nilin.opex.utility error-handler diff --git a/otp/otp-app/Dockerfile b/otp/otp-app/Dockerfile new file mode 100644 index 000000000..29f0c2703 --- /dev/null +++ b/otp/otp-app/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:17-jdk +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] +HEALTHCHECK --interval=45s --start-period=30s --retries=5 CMD curl -sf 'http://localhost:8080/actuator/health' >/dev/null || exit 1 diff --git a/otp/otp-app/pom.xml b/otp/otp-app/pom.xml new file mode 100644 index 000000000..49cb4f0c0 --- /dev/null +++ b/otp/otp-app/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + + co.nilin.opex.matching.otp + otp + 1.0.1-beta.7 + + + otp-app + otp-app + + + + org.jetbrains.kotlin + kotlin-reflect + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.cloud + spring-cloud-starter-consul-all + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + org.postgresql + r2dbc-postgresql + runtime + + + io.springfox + springfox-boot-starter + 3.0.0 + + + com.sun.mail + jakarta.mail + 2.0.1 + + + jakarta.activation + jakarta.activation-api + 2.0.1 + + + dev.samstevens.totp + totp + 1.7.1 + + + com.google.zxing + core + 3.5.3 + + com.google.zxing + javase + 3.5.3 + + + co.nilin.opex.utility + error-handler + + + co.nilin.opex.utility + interceptors + + + io.mockk + mockk + + + io.micrometer + micrometer-registry-prometheus + runtime + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/OTPApp.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/OTPApp.kt new file mode 100644 index 000000000..0e161d374 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/OTPApp.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.otp.app + +import co.nilin.opex.utility.error.EnableOpexErrorHandler +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@ComponentScan("co.nilin.opex") +@EnableOpexErrorHandler +class OTPApp + +fun main(args: Array) { + runApplication(*args) +} diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/PostgresConfig.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/PostgresConfig.kt new file mode 100644 index 000000000..94cc7cc4a --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/PostgresConfig.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.otp.app.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.core.io.Resource +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories +import org.springframework.r2dbc.core.DatabaseClient + +@Configuration +@EnableR2dbcRepositories(basePackages = ["co.nilin.opex"]) +@Profile("!test") +class PostgresConfig( + db: DatabaseClient, + @Value("classpath:schema.sql") private val schemaResource: Resource +) { + init { + val schemaReader = schemaResource.inputStream.reader() + val schema = schemaReader.readText().trim() + schemaReader.close() + val initDb = db.sql { schema } + initDb.then().block() + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/SecurityConfig.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/SecurityConfig.kt new file mode 100644 index 000000000..26a509525 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/SecurityConfig.kt @@ -0,0 +1,69 @@ +package co.nilin.opex.otp.app.config + +import co.nilin.opex.otp.app.utils.AudienceValidator +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Profile +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +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.web.server.SecurityWebFilterChain +import org.springframework.web.reactive.function.client.WebClient + +@EnableWebFluxSecurity +@Profile("!test") +class SecurityConfig(private val webClient: WebClient) { + + @Value("\${app.auth.cert-url}") + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String + + @Bean + fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { + http.csrf().disable() + .authorizeExchange() + .pathMatchers("/actuator/**").permitAll() + .pathMatchers("/v1/otp/**").permitAll() + .pathMatchers("/v1/totp/**").permitAll() + .anyExchange().authenticated() + .and() + .oauth2ResourceServer() + .jwt() + return http.build() + } + + + @Bean + @Throws(Exception::class) + fun reactiveJwtDecoder(): ReactiveJwtDecoder? { + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(webClient) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder + } + + @Bean + fun passwordEncoder(): BCryptPasswordEncoder { + return BCryptPasswordEncoder() + } +} diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/WebclientConfig.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/WebclientConfig.kt new file mode 100644 index 000000000..7abff0f3f --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/WebclientConfig.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.otp.app.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class WebclientConfig { + + @Bean + fun webClient(): WebClient { + return WebClient.builder().build() + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/OTPController.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/OTPController.kt new file mode 100644 index 000000000..0b44a084b --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/OTPController.kt @@ -0,0 +1,65 @@ +package co.nilin.opex.otp.app.controller + +import co.nilin.opex.common.OpexError +import co.nilin.opex.otp.app.data.NewOTPRequest +import co.nilin.opex.otp.app.data.OTPResult +import co.nilin.opex.otp.app.data.VerifyOTPRequest +import co.nilin.opex.otp.app.model.OTPType +import co.nilin.opex.otp.app.service.OTPService +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +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("/v1/otp") +class OTPController( + private val otpService: OTPService, + @Value("\${otp.response-enabled}") private val otpCodeResponseEnabled: Boolean, +) { + + //TODO IMPORTANT: remove in production + data class TempOtpResponse(val otp: String?) + //TODO IMPORTANT: remove in production + + //TODO IMPORTANT: remove in production + @PostMapping + suspend fun requestOTP(@RequestBody request: NewOTPRequest): ResponseEntity { + validateOTPRequest(request.receivers.map { it.type }) + val code = if (request.receivers.size == 1) + otpService.requestOTP( + request.receivers[0].receiver, + request.receivers[0].type, + request.userId, + request.action + ) + else + otpService.requestCompositeOTP(request.receivers.toSet(), request.userId, request.action) + val tempOtpResponse = if (otpCodeResponseEnabled) code else null + return ResponseEntity.status(HttpStatus.CREATED).body(TempOtpResponse(tempOtpResponse)) + } + + @PostMapping("/verify") + suspend fun verifyOTP(@RequestBody request: VerifyOTPRequest): OTPResult { + validateOTPRequest(request.otpCodes.map { it.type }) + val result = if (request.otpCodes.size == 1) + otpService.verifyOTP(request.otpCodes[0].code, request.userId) + else + otpService.verifyCompositeOTP(request.otpCodes.toSet(), request.userId) + return result + } + + private fun validateOTPRequest(request: List) { + if (request.isEmpty() || request.contains(OTPType.COMPOSITE)) + throw OpexError.BadRequest.exception() + + val map = request.groupingBy { it }.eachCount() + map.forEach { entry -> + if (entry.value > 1) + throw OpexError.BadRequest.exception() + } + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/TOTPController.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/TOTPController.kt new file mode 100644 index 000000000..115db992e --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/TOTPController.kt @@ -0,0 +1,52 @@ +package co.nilin.opex.otp.app.controller + +import co.nilin.opex.otp.app.data.SetupTOTPRequest +import co.nilin.opex.otp.app.data.SetupTOTPResponse +import co.nilin.opex.otp.app.data.VerifyOTPResponse +import co.nilin.opex.otp.app.data.VerifyTOTPRequest +import co.nilin.opex.otp.app.model.TOTPQueryResponse +import co.nilin.opex.otp.app.service.TOTPService +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/v1/totp") +class TOTPController(private val service: TOTPService) { + + @PostMapping("/setup") + suspend fun setup(@RequestBody request: SetupTOTPRequest): SetupTOTPResponse { + val uri = service.setupTOTP(request.userId, request.label) + return SetupTOTPResponse(uri) + } + + @PostMapping("/setup/verify") + suspend fun verifySetup(@RequestBody request: VerifyTOTPRequest) { + service.verifyAndMarkActivated(request.userId, request.code) + } + + @PostMapping("/verify") + suspend fun verify(@RequestBody request: VerifyTOTPRequest): VerifyOTPResponse { + val isVerified = service.verifyTOTP(request.userId, request.code) + return VerifyOTPResponse(isVerified) + } + + @GetMapping("/query/{userId}") + suspend fun query(@PathVariable userId: String): TOTPQueryResponse { + val totp = service.findTOTP(userId) + return TOTPQueryResponse( + totp?.userId ?: userId, + totp?.isEnabled ?: false, + totp?.isActivated ?: false, + ) + } + + @DeleteMapping + suspend fun remove(@RequestBody request: VerifyTOTPRequest) { + service.removeTOTP(request.userId, request.code) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/NewOTPRequest.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/NewOTPRequest.kt new file mode 100644 index 000000000..533213744 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/NewOTPRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.otp.app.data + +data class NewOTPRequest( + val userId: String, + val receivers: List, + val action: String? +) diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/NewOTPResponse.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/NewOTPResponse.kt new file mode 100644 index 000000000..7dd115c30 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/NewOTPResponse.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.otp.app.data + +data class NewOTPResponse(val tracingCode: String) diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPCode.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPCode.kt new file mode 100644 index 000000000..499fe8565 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPCode.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.otp.app.data + +import co.nilin.opex.otp.app.model.OTPType + +data class OTPCode( + val type: OTPType, + val code: String +) diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPReceiver.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPReceiver.kt new file mode 100644 index 000000000..86a3657da --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPReceiver.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.otp.app.data + +import co.nilin.opex.otp.app.model.OTPType + +data class OTPReceiver( + val receiver: String, + val type: OTPType, +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPResult.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPResult.kt new file mode 100644 index 000000000..de6aa8403 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/OTPResult.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.otp.app.data + +data class OTPResult(val result: Boolean, val type: OTPResultType, val tracingCode: String? = null) + +enum class OTPResultType(val isValid: Boolean = false) { + + VALID(true), EXPIRED, INCORRECT, INVALID +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SMSProviderType.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SMSProviderType.kt new file mode 100644 index 000000000..62c092aa1 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SMSProviderType.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.otp.app.data + +enum class SMSProviderType { + KAVENEGAR, + TWILIO, + SMSTO +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SetupTOTPRequest.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SetupTOTPRequest.kt new file mode 100644 index 000000000..368e43706 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SetupTOTPRequest.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.otp.app.data + +data class SetupTOTPRequest( + val userId: String, + val label: String? +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SetupTOTPResponse.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SetupTOTPResponse.kt new file mode 100644 index 000000000..00751c14f --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/SetupTOTPResponse.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.otp.app.data + +data class SetupTOTPResponse( + val uri: String +) diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyOTPRequest.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyOTPRequest.kt new file mode 100644 index 000000000..636060259 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyOTPRequest.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.otp.app.data + +data class VerifyOTPRequest( + val userId: String, + val otpCodes: List +) diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyOTPResponse.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyOTPResponse.kt new file mode 100644 index 000000000..7ab95c8dd --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyOTPResponse.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.otp.app.data + +data class VerifyOTPResponse(val result: Boolean) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyTOTPRequest.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyTOTPRequest.kt new file mode 100644 index 000000000..4931e111a --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/data/VerifyTOTPRequest.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.otp.app.data + +data class VerifyTOTPRequest( + val userId: String, + val code: String +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTP.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTP.kt new file mode 100644 index 000000000..96e24135d --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTP.kt @@ -0,0 +1,26 @@ +package co.nilin.opex.otp.app.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("otp") +data class OTP( + val code: String, + val receiver: String, + val userId: String, + val action: String, + val tracingCode: String, + val type: OTPType, + val expiresAt: LocalDateTime, + val requestDate: LocalDateTime = LocalDateTime.now(), + val isVerified: Boolean = false, + val isActive: Boolean = true, + val verifyTime: LocalDateTime? = null, + @Id val id: Long? = null, +) { + + fun isExpired(): Boolean { + return expiresAt.isBefore(LocalDateTime.now()) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTPConfig.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTPConfig.kt new file mode 100644 index 000000000..712bda511 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTPConfig.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.otp.app.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("otp_config") +data class OTPConfig( + @Id val type: OTPType, + var expireTimeSeconds: Int = 60, + var charCount: Int = 6, + var includeAlphabetChars: Boolean = false, + var isEnabled: Boolean = true, + var isActivated: Boolean = false, + var messageTemplate: String = "%s", +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTPType.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTPType.kt new file mode 100644 index 000000000..938049cc6 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/OTPType.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.otp.app.model + +enum class OTPType(val compositeOrder: Int) { + + SMS(0), EMAIL(1), + COMPOSITE(99) +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/SMSProvider.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/SMSProvider.kt new file mode 100644 index 000000000..e4e2bc815 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/SMSProvider.kt @@ -0,0 +1,19 @@ +package co.nilin.opex.otp.app.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + + +@Table(name = "sms_provider") +class SMSProvider( + @Id + val id: String, + val enabled: Boolean, + val baseUrl: String, + val apiKey: String?, + val template: String?, + val username: String?, + val password: String?, + val sender: String?, + val extraConfig: String? +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/SMSProviderRoute.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/SMSProviderRoute.kt new file mode 100644 index 000000000..e9a46f8e8 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/SMSProviderRoute.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.otp.app.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table(name = "sms_provider_route") +class SMSProviderRoute( + @Id + val id: Long? = null, + val prefix: String, + val provider: String, + val enabled: Boolean = true +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTP.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTP.kt new file mode 100644 index 000000000..d7aa0494b --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTP.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.otp.app.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("totp") +data class TOTP( + val userId: String, + val secret: String, + val label: String? = null, + var isEnabled: Boolean = true, + var isActivated: Boolean = false, + val createdAt: LocalDateTime = LocalDateTime.now(), + @Id val id: Long? = null +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPConfig.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPConfig.kt new file mode 100644 index 000000000..06bb0e792 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPConfig.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.otp.app.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("TOTP") +data class TOTPConfig( + var secretChars: Int, + var issuer: String, + @Id val id: Boolean = true +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPQueryResponse.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPQueryResponse.kt new file mode 100644 index 000000000..81dfa98b5 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPQueryResponse.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.otp.app.model + +data class TOTPQueryResponse( + val userId: String, + val isEnabled: Boolean, + val isActivated: Boolean, +) diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/KaveNegarProxy.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/KaveNegarProxy.kt new file mode 100644 index 000000000..8f1d56b24 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/KaveNegarProxy.kt @@ -0,0 +1,49 @@ +package co.nilin.opex.otp.app.proxy + +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import co.nilin.opex.otp.app.data.SMSProviderType +import co.nilin.opex.otp.app.repository.SMSProviderRepository +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import org.springframework.web.util.UriComponentsBuilder + +@Component +class KaveNegarProxy( + private val webClient: WebClient, + private val smsProviderRepository: SMSProviderRepository, +) : SMSProvider { + + override val type = SMSProviderType.KAVENEGAR + + + private val logger by LoggerDelegate() + + override suspend fun send(receiver: String, message: String): Boolean { + val config = smsProviderRepository.findById(type.name) ?: throw OpexError.UnableToSendOTP.exception() + val baseUrl = "${config.baseUrl}/${config.apiKey}/" + + val uri = UriComponentsBuilder.fromUriString("$baseUrl/verify/lookup.json") + .queryParam("receptor", receiver) + .queryParam("template", config.template) + .queryParam("token", message) + .build().toUri() + + return try { + val response = webClient.get() + .uri(uri) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingleOrNull() + logger.debug("Message sent to receiver $receiver.\n$response") + true + } catch (e: Exception) { + logger.error("Failed to send SMS", e) + false + } + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/SMSProvider.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/SMSProvider.kt new file mode 100644 index 000000000..69afef42e --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/SMSProvider.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.otp.app.proxy + +import co.nilin.opex.otp.app.data.SMSProviderType + +interface SMSProvider { + + val type: SMSProviderType + + suspend fun send( + receiver: String, + message: String, + ): Boolean +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/SMSTOProxy.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/SMSTOProxy.kt new file mode 100644 index 000000000..e9d928df5 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/SMSTOProxy.kt @@ -0,0 +1,62 @@ +package co.nilin.opex.otp.app.proxy + +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import co.nilin.opex.otp.app.data.SMSProviderType +import co.nilin.opex.otp.app.repository.SMSProviderRepository +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono + +@Component +class SMSTOProxy( + private val webClient: WebClient, + private val smsProviderRepository: SMSProviderRepository, +) : SMSProvider { + + override val type = SMSProviderType.SMSTO + + private val logger by LoggerDelegate() + + override suspend fun send( + receiver: String, + message: String, + ): Boolean { + + val config = smsProviderRepository.findById(type.name) + ?: throw OpexError.UnableToSendOTP.exception() + + val request = SMSRequest( + to = receiver, + message = message, + sender_id = config.sender + ) + + return try { + val response = webClient.post() + .uri("${config.baseUrl}/sms/send") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${config.apiKey}") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .bodyToMono() + .awaitSingleOrNull() + + logger.debug("Message sent to receiver $receiver.\n$response") + true + } catch (e: Exception) { + logger.error("Failed to send SMS", e) + false + } + } + + data class SMSRequest( + val to: String, + val message: String, + val sender_id: String? = null, + ) +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/TWILIOProxy.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/TWILIOProxy.kt new file mode 100644 index 000000000..ed9c00fb7 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/TWILIOProxy.kt @@ -0,0 +1,73 @@ +package co.nilin.opex.otp.app.proxy + +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import co.nilin.opex.otp.app.data.SMSProviderType +import co.nilin.opex.otp.app.repository.SMSProviderRepository +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException +import org.springframework.web.reactive.function.client.bodyToMono + +@Component +class TWILIOProxy( + private val webClient: WebClient, + private val smsProviderRepository: SMSProviderRepository, +) : SMSProvider { + + override val type = SMSProviderType.TWILIO + + private val logger by LoggerDelegate() + + override suspend fun send( + receiver: String, + message: String, + ): Boolean { + + val config = smsProviderRepository.findById(type.name) + ?: throw OpexError.UnableToSendOTP.exception() + + val accountSid = config.username + ?: throw IllegalStateException("Twilio Account SID is not configured") + + val authToken = config.apiKey + ?: throw IllegalStateException("Twilio Auth Token is not configured") + + val sender = config.sender + ?: throw IllegalStateException("Twilio sender number is not configured") + + val formData = LinkedMultiValueMap().apply { + add("To", receiver) + add("From", sender) + add("Body", message) + } + + return try { + val response = webClient.post() + .uri("${config.baseUrl}/2010-04-01/Accounts/$accountSid/Messages.json") + .headers { + it.setBasicAuth(accountSid, authToken) + } + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .onStatus({ it.isError }) { it.createException() } + .bodyToMono() + .awaitSingleOrNull() + + logger.debug("Message sent to receiver $receiver.\n$response") + true + } catch (e: WebClientResponseException) { + logger.error( + "Twilio error: ${e.statusCode}\n${e.responseBodyAsString}", + e + ) + logger.error("Failed to send SMS", e) + false + } + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/data/KaveSendResponse.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/data/KaveSendResponse.kt new file mode 100644 index 000000000..ba1f9da58 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/proxy/data/KaveSendResponse.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.otp.app.proxy.data + +data class KaveSendResponse( + val messageId: Long, + val message: String?, + val status: Int, + val statusText: String?, + val sender: String?, + val receptor: String?, + val cost: Long +) \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/OTPConfigRepository.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/OTPConfigRepository.kt new file mode 100644 index 000000000..0d28a1652 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/OTPConfigRepository.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.otp.app.repository + +import co.nilin.opex.otp.app.model.OTPConfig +import co.nilin.opex.otp.app.model.OTPType +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface OTPConfigRepository : ReactiveCrudRepository \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/OTPRepository.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/OTPRepository.kt new file mode 100644 index 000000000..b95e2ec61 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/OTPRepository.kt @@ -0,0 +1,26 @@ +package co.nilin.opex.otp.app.repository + +import co.nilin.opex.otp.app.model.OTP +import co.nilin.opex.otp.app.model.OTPType +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface OTPRepository : CoroutineCrudRepository { + + suspend fun findByTracingCode(traceCode: String): OTP? + + @Query("select * from otp where user_id = :userId and is_active is true") + suspend fun findActiveByUserId(userId: String): OTP? + + @Query("select * from otp where is_active is true and ((receiver = :receiver and type = :type) or user_id = :userId)") + suspend fun findActiveByReceiverAndTypeOrUserId(receiver: String, type: OTPType, userId: String): OTP? + + @Query("update otp set is_active = false where id = :id") + suspend fun markInactive(id: Long) + + @Query("update otp set is_verified = true, is_active = false, verify_time = current_timestamp where id = :id") + suspend fun markVerified(id: Long) + +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/SMSProviderRepository.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/SMSProviderRepository.kt new file mode 100644 index 000000000..74dd1e5b5 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/SMSProviderRepository.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.otp.app.repository + +import co.nilin.opex.otp.app.model.SMSProvider +import co.nilin.opex.otp.app.model.SMSProviderRoute +import co.nilin.opex.otp.app.model.TOTPConfig +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface SMSProviderRepository : CoroutineCrudRepository { + @Query("select * from sms_provider where id=:type") + suspend fun findConfig(type: String): SMSProvider +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/SMSProviderRouteRepository.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/SMSProviderRouteRepository.kt new file mode 100644 index 000000000..2a51d6e6d --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/SMSProviderRouteRepository.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.otp.app.repository + +import co.nilin.opex.otp.app.model.SMSProviderRoute +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface SMSProviderRouteRepository : CoroutineCrudRepository { + + fun findAllByEnabledTrue(): List +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/TOTPConfigRepository.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/TOTPConfigRepository.kt new file mode 100644 index 000000000..280827b3f --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/TOTPConfigRepository.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.otp.app.repository + +import co.nilin.opex.otp.app.model.TOTPConfig +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface TOTPConfigRepository : CoroutineCrudRepository { + + @Query("select * from totp_config limit 1") + suspend fun findOne(): TOTPConfig +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/TOTPRepository.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/TOTPRepository.kt new file mode 100644 index 000000000..521302e22 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/repository/TOTPRepository.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.otp.app.repository + +import co.nilin.opex.otp.app.model.TOTP +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface TOTPRepository : CoroutineCrudRepository { + + suspend fun findByUserId(userId: String): TOTP? + + @Query("update totp set is_activated = true where id = :id") + suspend fun markActivated(id:Long) +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/OTPService.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/OTPService.kt new file mode 100644 index 000000000..49ef7f996 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/OTPService.kt @@ -0,0 +1,202 @@ +package co.nilin.opex.otp.app.service + +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import co.nilin.opex.otp.app.data.OTPCode +import co.nilin.opex.otp.app.data.OTPReceiver +import co.nilin.opex.otp.app.data.OTPResult +import co.nilin.opex.otp.app.data.OTPResultType +import co.nilin.opex.otp.app.model.OTP +import co.nilin.opex.otp.app.model.OTPConfig +import co.nilin.opex.otp.app.model.OTPType +import co.nilin.opex.otp.app.repository.OTPConfigRepository +import co.nilin.opex.otp.app.repository.OTPRepository +import co.nilin.opex.otp.app.service.message.MessageManager +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.* +import kotlin.math.pow +import kotlin.random.Random + +@Service +class OTPService( + private val repository: OTPRepository, + private val configRepository: OTPConfigRepository, + private val messageManager: MessageManager, + private val encoder: BCryptPasswordEncoder +) { + + private val logger by LoggerDelegate() + + suspend fun requestOTP(receiver: String, type: OTPType, userId: String, action: String?): String { + checkActiveOTP(receiver, type, userId) + val config = getConfig(type) + val code = generateCode(config.charCount, config.includeAlphabetChars) + messageManager.sendMessage(config, type, code, receiver) + storeOTP(receiver, type, code, config, userId, action) + return code + } + + suspend fun requestCompositeOTP(receivers: Set, userId: String, action: String?): String { + val type = OTPType.COMPOSITE + val mainConfig = getConfig(type) + val receiver = receivers.joinToString(",") { it.receiver } + checkActiveOTP(receiver, type, userId) + + val compositeCode = StringBuilder() + receivers.forEach { + val config = getConfig(it.type) + val code = generateCode(config.charCount, config.includeAlphabetChars) + messageManager.sendMessage(config, type, code, receiver) + compositeCode.append(code) + } + + storeOTP(receiver, type, compositeCode.toString(), mainConfig, userId, action) + return compositeCode.toString() + } + + private suspend fun storeOTP( + receiver: String, + type: OTPType, + code: String, + config: OTPConfig, + userId: String, + action: String? + ): String { + val expireTime = LocalDateTime.now().plusSeconds(config.expireTimeSeconds.toLong()) + val newOtp = OTP( + code.encode(), + receiver, + userId, + action ?: "UNSPECIFIED", + UUID.randomUUID().toString(), + type, + expireTime, + ) + + logger.debug("${newOtp.tracingCode} -> $code") + val otp = repository.save(newOtp) + return otp.tracingCode + } + + private suspend fun checkActiveOTP(receiver: String, type: OTPType, userId: String) { + // Check whether receiver has an active otp of specified type + repository.findActiveByReceiverAndTypeOrUserId(receiver, type, userId)?.let { + if (it.isExpired()) + repository.markInactive(it.id!!) + else + throw OpexError.OTPAlreadyRequested.exception() + } + } + + private suspend fun getConfig(type: OTPType): OTPConfig { + return configRepository.findById(type).awaitSingleOrNull()?.apply { + if (!isEnabled) + throw OpexError.OTPDisabled.exception() + } ?: throw OpexError.OTPConfigNotFound.exception() + } + + suspend fun verifyOTP(code: String, userId: String): OTPResult { + val otp = repository.findActiveByUserId(userId) + val verifyOtpResponse = verifyOtp(code, otp, userId) + + val tracingCode = if (verifyOtpResponse.type == OTPResultType.VALID) otp?.tracingCode else null + return OTPResult(verifyOtpResponse.result, verifyOtpResponse.type, tracingCode) + } + + suspend fun verifyCompositeOTP(codes: Set, userId: String): OTPResult { + val otp = repository.findActiveByUserId(userId) + ?: return OTPResult(false, OTPResultType.INVALID) + + if (otp.type != OTPType.COMPOSITE) + throw OpexError.BadRequest.exception() + + val code = reconstructCode(codes) + val result = verifyOtp(code, otp, userId) + + return if (result.type == OTPResultType.VALID) { + result.copy(tracingCode = otp.tracingCode) + } else { + result.copy(tracingCode = null) + } + } + + @Deprecated("Use userId instead") + suspend fun verifyOTP(code: String, userId: String, tracingCode: String?): OTPResult { + val otp = tracingCode?.let { repository.findByTracingCode(it) } + ?: return OTPResult(false, OTPResultType.INVALID) + + val result = verifyOtp(code, otp, userId) + return if (result.type == OTPResultType.VALID) { + result.copy(tracingCode = otp.tracingCode) + } else { + result.copy(tracingCode = null) + } + } + + @Deprecated("Use userId instead") + suspend fun verifyCompositeOTP(codes: Set, userId: String, tracingCode: String?): OTPResult { + val otp = tracingCode?.let { repository.findByTracingCode(it) } + ?: return OTPResult(false, OTPResultType.INVALID) + + if (otp.type != OTPType.COMPOSITE) + throw OpexError.BadRequest.exception() + + val code = reconstructCode(codes) + val result = verifyOtp(code, otp, userId) + + return if (result.type == OTPResultType.VALID) { + result.copy(tracingCode = otp.tracingCode) + } else { + result.copy(tracingCode = null) + } + } + + private suspend fun reconstructCode(codes: Set): String { + return codes.sortedBy { it.type.compositeOrder }.joinToString("") { it.code } + } + + private suspend fun verifyOtp(code: String, otp: OTP?, userId: String): OTPResult { + if (otp == null) { + logger.warn("OTP not found") + return OTPResult(false, OTPResultType.INVALID) + } + + if (otp.userId != userId) { + logger.warn("OTP userId mismatch: expected=${otp.userId}, actual=$userId") + return OTPResult(false, OTPResultType.INVALID) + } + + if (otp.isExpired()) { + logger.warn("OTP expired. tracingCode=${otp.tracingCode}") + return OTPResult(false, OTPResultType.EXPIRED) + } + + if (!encoder.matches(code, otp.code)) { + logger.warn("OTP incorrect. tracingCode=${otp.tracingCode}") + return OTPResult(false, OTPResultType.INCORRECT) + } + + repository.markVerified(otp.id!!) + logger.info("OTP verified successfully. tracingCode=${otp.tracingCode}") + + return OTPResult(true, OTPResultType.VALID) + } + + private suspend fun generateCode(length: Int): String { + val min = 10.0.pow(length - 1).toInt() + val max = 10.0.pow(length).toInt() - 1 + return Random.nextInt(min, max + 1).toString() + } + + private fun generateCode(length: Int, includeAlpha: Boolean): String { + val chars = if (includeAlpha) ('A'..'Z') + ('a'..'z') + ('0'..'9') else ('0'..'9').toList() + return (1..length).map { chars.random() }.joinToString("") + } + + private fun String.encode(): String { + return encoder.encode(this) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/SMSProviderRouter.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/SMSProviderRouter.kt new file mode 100644 index 000000000..143c41e74 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/SMSProviderRouter.kt @@ -0,0 +1,34 @@ +package co.nilin.opex.otp.app.service; + +import co.nilin.opex.otp.app.data.SMSProviderType; +import co.nilin.opex.otp.app.proxy.SMSProvider; +import co.nilin.opex.otp.app.repository.SMSProviderRouteRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component; + +@Component +class SMSProviderRouter( + providers: List, + private val routeRepository: SMSProviderRouteRepository, + @Value("\${otp.sms.provider.default}") + private val defaultSmsProvider: SMSProviderType?, +) { + + private val providerMap: Map = providers.associateBy { it.type } + + suspend fun getProvider(receiver: String): SMSProvider { + val routes = routeRepository.findAllByEnabledTrue() + + val providerType = routes + .sortedByDescending { it.prefix.length } + .firstOrNull { receiver.startsWith(it.prefix) } + ?.provider + ?.let(SMSProviderType::valueOf) + ?: defaultSmsProvider + + return providerMap[providerType] + ?: throw IllegalStateException( + "SMS provider $providerType is configured but no implementation was found." + ) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt new file mode 100644 index 000000000..7ee56a0eb --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt @@ -0,0 +1,89 @@ +package co.nilin.opex.otp.app.service + +import co.nilin.opex.common.OpexError +import co.nilin.opex.otp.app.model.TOTP +import co.nilin.opex.otp.app.repository.TOTPConfigRepository +import co.nilin.opex.otp.app.repository.TOTPRepository +import dev.samstevens.totp.code.DefaultCodeGenerator +import dev.samstevens.totp.code.DefaultCodeVerifier +import dev.samstevens.totp.code.HashingAlgorithm +import dev.samstevens.totp.qr.QrData +import dev.samstevens.totp.secret.DefaultSecretGenerator +import dev.samstevens.totp.time.SystemTimeProvider +import org.springframework.stereotype.Service + +@Service +class TOTPService( + private val repository: TOTPRepository, + private val configRepository: TOTPConfigRepository +) { + + suspend fun setupTOTP(userId: String, label: String? = null): String { + val config = configRepository.findOne() + repository.findByUserId(userId)?.let { throw OpexError.TOTPAlreadyRegistered.exception() } + val secret = generateSecret() + val uri = generateUri(userId, config.issuer, secret, label) + repository.save(TOTP(userId, secret, label)) + return uri + } + + suspend fun verifyAndMarkActivated(userId: String, code: String) { + val totp = repository.findByUserId(userId) ?: throw OpexError.TOTPNotFound.exception() + val isValid = isCodeValid(totp.secret, code.trim()) + if (isValid) + repository.markActivated(totp.id!!) + else + throw OpexError.InvalidTOTPCode.exception() + } + + suspend fun verifyTOTP(userId: String, code: String): Boolean { + val totp = repository.findByUserId(userId) ?: throw OpexError.TOTPNotFound.exception() + if (!totp.isActivated) throw OpexError.TOTPSetupIncomplete.exception() + return isCodeValid(totp.secret, code.trim()) + } + + suspend fun removeTOTP(userId: String, code: String) { + val totp = repository.findByUserId(userId) ?: throw OpexError.TOTPNotFound.exception() + if (!totp.isActivated) + repository.deleteById(totp.id!!) + else { + val isValid = isCodeValid(totp.secret, code.trim()) + if (isValid) + repository.deleteById(totp.id!!) + else + throw OpexError.InvalidTOTPCode.exception() + } + } + + suspend fun findTOTP(userId: String): TOTP? { + return repository.findByUserId(userId) + } + + private suspend fun generateSecret(): String { + val config = configRepository.findOne() + val generator = DefaultSecretGenerator(config.secretChars) + return generator.generate() + } + + private fun generateUri(userId: String, issuer: String, secret: String, label: String? = null): String { + val data = QrData.Builder() + .label(label ?: userId) + .secret(secret) + .issuer(issuer) + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build() + return data.uri + } + + private fun isCodeValid(secret: String, code: String): Boolean { + val timeProvider = SystemTimeProvider() + val generator = DefaultCodeGenerator(HashingAlgorithm.SHA1, 6) + val verifier = DefaultCodeVerifier(generator, timeProvider).apply { + setTimePeriod(30) + setAllowedTimePeriodDiscrepancy(3) + } + return verifier.isValidCode(secret, code) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/EmailSender.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/EmailSender.kt new file mode 100644 index 000000000..123edaf16 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/EmailSender.kt @@ -0,0 +1,95 @@ +package co.nilin.opex.otp.app.service.message + +import co.nilin.opex.common.utils.LoggerDelegate +import jakarta.mail.Message +import jakarta.mail.Session +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeMessage +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.Properties + +@Component +class EmailSender( + @Value("\${otp.email.host}") + private val host: String, + + @Value("\${otp.email.port}") + private val port: String, + + @Value("\${otp.email.username}") + private val username: String, + + @Value("\${otp.email.password}") + private val password: String, + + @Value("\${otp.email.from}") + private val fromAddress: String, + + @Value("\${otp.email.proxy.enabled}") + private val proxyEnabled: Boolean, + + @Value("\${otp.email.proxy.host}") + private val proxyHost: String?, + + @Value("\${otp.email.proxy.port}") + private val proxyPort: String? +) : MessageSender { + + private val logger by LoggerDelegate() + + override suspend fun send( + receiver: String, + message: String, + metadata: Map + ): Boolean { + + val subject = "Your otp code" + + try { + // 🔥 SOCKS must be JVM-level (NOT JavaMail props) + if (proxyEnabled) { + System.setProperty("socksProxyHost", proxyHost) + System.setProperty("socksProxyPort", proxyPort) + } + + val props = Properties().apply { + put("mail.smtp.host", host) + put("mail.smtp.port", port) + put("mail.smtp.auth", "true") + put("mail.smtp.starttls.enable", "false") + put("mail.smtp.ssl.enable", "true") + put("mail.smtp.from", fromAddress) + put("mail.smtp.starttls.enable", "false") + put("mail.smtp.starttls.required", "false") + + put("mail.smtp.socketFactory.port", port) + put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory") + put("mail.smtp.socketFactory.fallback", "false") + + put("mail.smtp.ssl.protocols", "TLSv1.2 TLSv1.3") + } + + val session = Session.getInstance(props) + session.debug=true + val msg = MimeMessage(session).apply { + setSubject(subject) + setFrom(InternetAddress(fromAddress )) + addRecipient(Message.RecipientType.TO, InternetAddress(receiver)) + setContent(message, "text/html; charset=utf-8") + } + + session.getTransport("smtp").use { transport -> + transport.connect(host, port.toInt(), username, password) + transport.sendMessage(msg, msg.allRecipients) + } + + logger.info("Successfully sent email message") + return true + + } catch (e: Exception) { + logger.error("Failed to send email message", e) + return false + } + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/MessageManager.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/MessageManager.kt new file mode 100644 index 000000000..00a0f5869 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/MessageManager.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.otp.app.service.message + +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import co.nilin.opex.otp.app.model.OTPConfig +import co.nilin.opex.otp.app.model.OTPType +import org.springframework.stereotype.Component + +@Component +class MessageManager( + private val smsSender: SMSSender, + private val emailSender: EmailSender +) { + + private val logger by LoggerDelegate() + + suspend fun sendMessage(config: OTPConfig, otpType: OTPType, code: String, receiver: String) { + val message = String.format(config.messageTemplate, code) + if (config.isActivated) { + val result = getSender(otpType).send(receiver, message) + if (!result) + throw OpexError.UnableToSendOTP.exception() + } else { + logger.warn("OTP for type ${otpType.name} is not activated. Message will not be sent. $message -> $receiver") + } + } + + suspend fun getSender(type: OTPType): MessageSender { + return when (type) { + OTPType.SMS -> smsSender + OTPType.EMAIL -> emailSender + OTPType.COMPOSITE -> throw IllegalStateException("Composite sender not supported") + } + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/MessageSender.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/MessageSender.kt new file mode 100644 index 000000000..2fc474f40 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/MessageSender.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.otp.app.service.message + +interface MessageSender { + + suspend fun send(receiver: String, message: String, metadata: Map = emptyMap()): Boolean +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/SMSSender.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/SMSSender.kt new file mode 100644 index 000000000..c1365d8f4 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/message/SMSSender.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.otp.app.service.message + +import co.nilin.opex.otp.app.proxy.KaveNegarProxy +import co.nilin.opex.otp.app.service.SMSProviderRouter +import org.springframework.stereotype.Component + +@Component +class SMSSender( + private val router: SMSProviderRouter +) : MessageSender { + override suspend fun send(receiver: String, message: String, metadata: Map): Boolean { + val provider = router.getProvider(receiver) + return provider.send(receiver, message) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/utils/AudienceValidator.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..2a3c2d9c6 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.otp.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/resources/application.yml b/otp/otp-app/src/main/resources/application.yml new file mode 100644 index 000000000..10199ab14 --- /dev/null +++ b/otp/otp-app/src/main/resources/application.yml @@ -0,0 +1,69 @@ +server: + port: 8080 +spring: + application: + name: opex-otp + r2dbc: + url: r2dbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${DB_USER} + password: ${DB_PASS} + initialization-mode: always + pool: + enabled: true + initial-size: 5 + max-size: 20 + max-idle-time: 60s + validation-query: SELECT 1 + # initialization-mode: always + cloud: + bootstrap: + enabled: true + consul: + host: ${CONSUL_HOST:localhost} + port: 8500 + discovery: + #healthCheckPath: ${management.context-path}/health + instance-id: ${spring.application.name}:${server.port} + healthCheckInterval: 20s + prefer-ip-address: true +management: + endpoints: + web: + base-path: /actuator + exposure: + include: [ "health", "prometheus", "metrics", "logging" ] + endpoint: + health: + show-details: when_authorized + metrics: + enabled: true + prometheus: + enabled: true +logging: + level: + co.nilin: INFO + # org.zalando.logbook: TRACE +app: + auth: + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} +otp: + response-enabled: ${OTP_CODE_RESPONSE_ENABLED:false} + sms: + provider: + default: ${DEFAULT_SMS_PROVIDER} + email: + host: ${SMTP_HOST} + port: ${SMTP_PORT} + username: ${SMTP_USER} + password: ${SMTP_PASS} + from: ${SMTP_FROM} + proxy: + enabled: ${SMTP_PROXY_ENABLED} + host: ${SMTP_PROXY_HOST} + port: ${SMTP_PROXY_PORT} + custom-message: + 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 diff --git a/otp/otp-app/src/main/resources/schema.sql b/otp/otp-app/src/main/resources/schema.sql new file mode 100644 index 000000000..7d043adb1 --- /dev/null +++ b/otp/otp-app/src/main/resources/schema.sql @@ -0,0 +1,81 @@ +create table if not exists otp +( + id serial primary key, + code text not null, + receiver text not null, + user_id text not null, + action text not null, + tracing_code text not null unique, + type varchar(16) not null, + expires_at timestamp not null, + request_date timestamp not null default current_timestamp, + is_verified boolean not null default false, + is_active boolean not null default true, + verify_time timestamp +); + +create table if not exists otp_config +( + type varchar(16) primary key, + expire_time_seconds integer not null default 60, + char_count integer not null default 6, + include_alphabet_chars boolean not null default false, + is_enabled boolean not null default true, + is_activated boolean not null default false, + message_template text not null default '%s', + check (char_count between 4 and 100) +); + +create table if not exists totp +( + id serial primary key, + user_id text not null unique, + secret text not null unique, + label text, + is_enabled boolean not null default true, + is_activated boolean not null default false, + created_at timestamp not null default current_timestamp +); + +create table if not exists totp_config +( + id boolean primary key default true, + secret_chars int not null default 64, + issuer text not null, + constraint id check (id is true) +); + +insert into otp_config +values ('EMAIL', 60, 8, true) +on conflict do nothing; + +insert into otp_config +values ('SMS') +on conflict do nothing; + +insert into otp_config +values ('COMPOSITE', 120) +on conflict do nothing; + +insert into totp_config +values (true, 128, 'Opex') +on conflict do nothing; + +CREATE TABLE if not exists sms_provider_route ( + id BIGSERIAL PRIMARY KEY, + prefix VARCHAR(32) NOT NULL, + provider VARCHAR(64) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE if not exists sms_provider ( + id VARCHAR(64) PRIMARY KEY, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + base_url TEXT NOT NULL, + api_key TEXT, + template VARCHAR(128), + username VARCHAR(128), + password VARCHAR(128), + sender VARCHAR(64), + extra_config TEXT +); \ No newline at end of file diff --git a/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/OTPControllerIT.kt b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/OTPControllerIT.kt new file mode 100644 index 000000000..f07ebd48b --- /dev/null +++ b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/OTPControllerIT.kt @@ -0,0 +1,38 @@ +package co.nilin.opex.otp.app.conttroller + + +import co.nilin.opex.otp.app.service.message.EmailSender +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + + +class EmailSenderIT { + @Disabled("Manual test: requires real SMTP + proxy") + @Test + fun `should send email via real smtp`() = runBlocking { + + val sender = EmailSender( + host = requireEnv("SMTP_HOST"), + port = requireEnv("SMTP_PORT"), + username = requireEnv("SMTP_USER"), + password = requireEnv("SMTP_PASS"), + fromAddress = requireEnv("SMTP_FROM"), + proxyEnabled = true, + proxyHost = requireEnv("SMTP_SOCKS_HOST"), + proxyPort = requireEnv("SMTP_SOCKS_PORT") + ) + + val result = sender.send( + receiver = requireEnv("SMTP_RECEIVER"), + message = "

Integration Test

", + metadata = emptyMap() + ) + + Assertions.assertTrue(result) + } + + private fun requireEnv(key: String): String = + System.getenv(key) ?: error("Missing env: $key") +} \ No newline at end of file diff --git a/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/SMSProviderRouterTest.kt b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/SMSProviderRouterTest.kt new file mode 100644 index 000000000..b26b2a21c --- /dev/null +++ b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/SMSProviderRouterTest.kt @@ -0,0 +1,80 @@ +import co.nilin.opex.otp.app.data.SMSProviderType +import co.nilin.opex.otp.app.model.SMSProviderRoute +import co.nilin.opex.otp.app.proxy.SMSProvider +import co.nilin.opex.otp.app.repository.SMSProviderRouteRepository +import co.nilin.opex.otp.app.service.SMSProviderRouter +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.Mockito.mock + +class SMSProviderRouterTest { + + private val repository: SMSProviderRouteRepository = mock() + + private val twilioProvider: SMSProvider = mock() + private val kavenegarProvider: SMSProvider = mock() + private val smsToProvider: SMSProvider = mock() + + private lateinit var router: SMSProviderRouter + + @BeforeEach + fun setUp() { + Mockito.`when`(twilioProvider.type).thenReturn(SMSProviderType.TWILIO) + Mockito.`when`(kavenegarProvider.type).thenReturn(SMSProviderType.KAVENEGAR) + Mockito.`when`(smsToProvider.type).thenReturn(SMSProviderType.SMSTO) + + router = SMSProviderRouter( + listOf( + twilioProvider, + kavenegarProvider, + smsToProvider + ), + repository, + SMSProviderType.KAVENEGAR + ) + } + + @Test + fun givenNoMatchingRoute_whenGetProvider_thenDefaultProviderReturned(): Unit = runBlocking { + Mockito.`when`(repository.findAllByEnabledTrue()) + .thenReturn( + listOf( + SMSProviderRoute(1,"+98", SMSProviderType.SMSTO.name), + SMSProviderRoute(2,"+989", SMSProviderType.KAVENEGAR.name), + ) + ) + val provider = router.getProvider("+447700123456") + assertEquals(kavenegarProvider, provider) + } + + @Test + fun givenNoMatchingRoute_whenGetProvider_thenLongestMatchedProviderReturned(): Unit = runBlocking { + Mockito.`when`(repository.findAllByEnabledTrue()) + .thenReturn( + listOf( + SMSProviderRoute(1,"+98", SMSProviderType.SMSTO.name), + SMSProviderRoute(2,"+989", SMSProviderType.KAVENEGAR.name), + ) + ) + val provider = router.getProvider("+989556677788") + assertEquals(kavenegarProvider, provider) + } + + @Test + fun givenNoMatchingRoute_whenGetProvider_thenMatchedProviderReturned(): Unit = runBlocking { + Mockito.`when`(repository.findAllByEnabledTrue()) + .thenReturn( + listOf( + SMSProviderRoute(1,"+98", SMSProviderType.SMSTO.name), + SMSProviderRoute(2,"+989", SMSProviderType.KAVENEGAR.name), + SMSProviderRoute(3,"+44", SMSProviderType.TWILIO.name), + + ) + ) + val provider = router.getProvider("+44555777999") + assertEquals(twilioProvider, provider) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/SMSTOProxyIT.kt b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/SMSTOProxyIT.kt new file mode 100644 index 000000000..22640f4c8 --- /dev/null +++ b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/SMSTOProxyIT.kt @@ -0,0 +1,51 @@ +package co.nilin.opex.otp.app.conttroller + +import co.nilin.opex.otp.app.data.SMSProviderType +import co.nilin.opex.otp.app.proxy.SMSTOProxy +import co.nilin.opex.otp.app.repository.SMSProviderRepository +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.web.reactive.function.client.WebClient +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Disabled + +class SMSTOProxyIT { + + private val repository = Mockito.mock(SMSProviderRepository::class.java) + + private val webClient = WebClient.builder().build() + + private val proxy = SMSTOProxy( + webClient = webClient, + smsProviderRepository = repository + ) + + @Test + @Disabled("Manual test: requires real apiKey") + fun sendRealSms(): Unit = runBlocking { + + Mockito.`when`( + repository.findById(SMSProviderType.SMSTO.name) + ).thenReturn( + co.nilin.opex.otp.app.model.SMSProvider( + id = SMSProviderType.SMSTO.name, + enabled = true, + baseUrl = "https://api.sms.to", + apiKey = "", + template = null, + username = null, + password = null, + sender = "", + extraConfig = null + ) + ) + + val success = proxy.send( + receiver = "", + message = "Manual SMS Test ${System.currentTimeMillis()}" + ) + + assertTrue(success) + } +} \ No newline at end of file diff --git a/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/TWILIOProxyIT.kt b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/TWILIOProxyIT.kt new file mode 100644 index 000000000..b403d386c --- /dev/null +++ b/otp/otp-app/src/test/kotlin/co/nilin/opex/otp/app/conttroller/TWILIOProxyIT.kt @@ -0,0 +1,60 @@ +package co.nilin.opex.otp.app.conttroller + +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import co.nilin.opex.otp.app.data.SMSProviderType +import co.nilin.opex.otp.app.proxy.SMSProvider +import co.nilin.opex.otp.app.proxy.SMSTOProxy +import co.nilin.opex.otp.app.proxy.TWILIOProxy +import co.nilin.opex.otp.app.repository.SMSProviderRepository +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono + + +class TWILIOProxyIT{ + private val repository = Mockito.mock(SMSProviderRepository::class.java) + + private val webClient = WebClient.builder().build() + + private val proxy = TWILIOProxy( + webClient = webClient, + smsProviderRepository = repository + ) + @Test + @Disabled("Manual test: requires real apiKey") + fun sendRealSms(): Unit = runBlocking { + + Mockito.`when`( + repository.findById(SMSProviderType.TWILIO.name) + ).thenReturn( + co.nilin.opex.otp.app.model.SMSProvider( + id = SMSProviderType.TWILIO.name, + enabled = true, + baseUrl = "https://api.twilio.com", + apiKey = "", + template = null, + username = "", + password = null, + sender = "", + extraConfig = null + ) + ) + val success = proxy.send( + receiver = "", + message = "Manual SMS Test ${System.currentTimeMillis()}" + ) + + assertTrue(success) + } + +} \ No newline at end of file diff --git a/otp/otp-app/src/test/resources/application.yml b/otp/otp-app/src/test/resources/application.yml new file mode 100644 index 000000000..dbeebe5c7 --- /dev/null +++ b/otp/otp-app/src/test/resources/application.yml @@ -0,0 +1,40 @@ +spring: + main: + web-application-type: none + + # 🚫 disable everything noisy + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration + + cloud: + consul: + enabled: false + config: + enabled: false + + vault: + enabled: false + + kafka: + bootstrap-servers: "" +otp: + response-enabled: ${OTP_CODE_RESPONSE_ENABLED:false} + sms: + provider: + default: ${DEFAULT_SMS_PROVIDER} + email: + host: ${SMTP_HOST} + port: ${SMTP_PORT} + username: ${SMTP_USER} + password: ${SMTP_PASS} + from: ${SMTP_FROM} + proxy: + enabled: ${SMTP_PROXY_ENABLED:false} + host: ${SMTP_PROXY_HOST} + port: ${SMTP_PROXY_PORT} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} \ No newline at end of file diff --git a/otp/pom.xml b/otp/pom.xml new file mode 100644 index 000000000..b63670ab8 --- /dev/null +++ b/otp/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + + core + co.nilin.opex + 1.0.1-beta.7 + + + co.nilin.opex.matching.otp + otp + otp + pom + OTP root of Opex + + + otp-app + + + + + co.nilin.opex + common + + + org.zalando + logbook-spring-boot-webflux-autoconfigure + 3.9.0 + + + org.testcontainers + junit-jupiter + 1.18.0 + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.mockito + mockito-core + + + + + org.mockito + mockito-core + 5.4.0 + test + + + + + + + co.nilin.opex.utility + error-handler + ${error-hanlder.version} + + + co.nilin.opex.utility + interceptors + ${interceptor.version} + + + + diff --git a/pom.xml b/pom.xml index 1f7431fd8..853d3c8d5 100644 --- a/pom.xml +++ b/pom.xml @@ -10,16 +10,16 @@ 1.0.1-beta.7 - 11 - 11 - 11 - 1.6.0 + 17 + 17 + 17 + 1.9.0 2.7.6 2021.0.5 - 1.0.1 - 1.0.0 - 1.0.0 + 1.2.25 + 1.0.8 true + 1.0.1-beta.38 @@ -32,7 +32,11 @@ user-management wallet bc-gateway + otp common + auth-gateway + profile + device-management @@ -87,7 +91,7 @@ co.nilin.opex common - ${project.version} + ${common.version} diff --git a/preferences-demo.yml b/preferences-demo.yml deleted file mode 100644 index 825039c7c..000000000 --- a/preferences-demo.yml +++ /dev/null @@ -1,237 +0,0 @@ -addressTypes: - - addressType: bitcoin - addressRegex: "*" - - addressType: ethereum - addressRegex: "*" -chains: - - name: bitcoin - addressType: bitcoin - scanners: - - url: http://bitcoin-scanner:8080 - maxBlockRange: 30 - delayOnRateLimit: 300 - schedule: - delay: 600 - errorDelay: 60 - timeout: 30 - maxRetries: 5 - confirmations: 0 - enabled: false - - name: ethereum - addressType: ethereum - scanners: - - url: http://ethereum-scanner:8080 - maxBlockRange: 30 - delayOnRateLimit: 300 - schedule: - delay: 15 - errorDelay: 7 - timeout: 30 - maxRetries: 5 - confirmations: 0 - enabled: false - - name: bsc - addressType: ethereum - scanners: - - url: http://bsc-scanner:8080 - maxBlockRange: 30 - delayOnRateLimit: 300 - schedule: - delay: 6 - errorDelay: 3 - timeout: 30 - maxRetries: 5 - confirmations: 0 -currencies: - - symbol: BTC - name: Bitcoin - precision: 0.000001 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: bitcoin - withdrawFee: 0.0001 - withdrawMin: 0.0001 - decimal: 0 - - symbol: ETH - name: Ethereum - precision: 0.000001 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: ethereum - withdrawFee: 0.00001 - withdrawMin: 0.000001 - decimal: 18 - - symbol: BNB - name: Binance - precision: 0.0001 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: bsc - withdrawFee: 0.00001 - withdrawMin: 0.000001 - decimal: 18 - - chain: bsc - symbol: WBNB - token: true - tokenAddress: 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c - tokenName: Wrapped BNB - withdrawFee: 0.01 - withdrawMin: 0.01 - decimal: 18 - - symbol: BUSD - name: Binance USD - precision: 0.01 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: bsc - token: true - tokenAddress: 0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56 - tokenName: BUSD Token - withdrawFee: 0.01 - withdrawMin: 0.01 - decimal: 18 - - symbol: IRT - name: Toman - precision: 0.1 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 -markets: - - leftSide: BTC - rightSide: BUSD - aliases: - - key: binance - alias: BTCBUSD - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: ETH - rightSide: BUSD - aliases: - - key: binance - alias: ETHBUSD - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: BNB - rightSide: BUSD - aliases: - - key: binance - alias: BNBBUSD - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: BTC - rightSide: IRT - aliases: - - key: binance - alias: BTCIRT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: ETH - rightSide: IRT - aliases: - - key: binance - alias: ETHIRT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: BNB - rightSide: IRT - aliases: - - key: binance - alias: BNBIRT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: BUSD - rightSide: IRT - aliases: - - key: binance - alias: BUSDIRT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 -userLimits: - - owner: 1 - action: withdraw - walletType: main - withdrawFee: 0.0001 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 -system: - walletTitle: system - walletLevel: system -admin: - walletTitle: admin - walletLevel: admin -userLevels: - - "*" - - "nofee" -auth: - whitelist: - enabled: false - file: /whitelist.txt diff --git a/preferences-dev.yml b/preferences-dev.yml deleted file mode 100644 index 920f89144..000000000 --- a/preferences-dev.yml +++ /dev/null @@ -1,412 +0,0 @@ -addressTypes: - - addressType: ethereum - addressRegex: "*" - - addressType: test-bitcoin - addressRegex: "*" -chains: - - name: test-bitcoin - addressType: test-bitcoin - scanners: - - url: http://bitcoin-scanner:8080 - maxBlockRange: 30 - delayOnRateLimit: 5 - maxParallelCall: 2 - schedules: - - workerType: MAIN - delay: 600 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 4 - enabled: false - - workerType: ERROR - delay: 600 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 4 - enabled: false - - workerType: DELAYED - delay: 300 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 2 - enabled: false - - name: test-ethereum - addressType: ethereum - scanners: - - url: http://ethereum-scanner:8080 - maxBlockRange: 30 - delayOnRateLimit: 5 - maxParallelCall: 3 - schedules: - - workerType: MAIN - delay: 15 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 10 - enabled: false - - workerType: ERROR - delay: 7 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 10 - enabled: false - - workerType: DELAYED - delay: 15 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 5 - enabled: false - - name: test-bsc - addressType: ethereum - scanners: - - url: http://bsc-scanner:8080 - maxBlockRange: 10 - delayOnRateLimit: 300 - maxParallelCall: 5 - schedules: - - workerType: MAIN - delay: 6 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 30 - - workerType: ERROR - delay: 3 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 20 - - workerType: DELAYED - delay: 10 - timeout: 30 - maxRetries: 5 - confirmations: 0 - maxBlockCount: 10 -currencies: - - symbol: IRT - name: Toman - precision: 0.1 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - gift: 1000000 - - symbol: BTC - name: Bitcoin (Test) - precision: 0.000001 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: test-bitcoin - withdrawFee: 0.0001 - withdrawMin: 0.0001 - decimal: 0 - gift: 5 - - symbol: ETH - name: Ethereum (Test) - precision: 0.000001 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: test-ethereum - withdrawFee: 0.00001 - withdrawMin: 0.000001 - decimal: 18 - gift: 100 - - symbol: USDT - name: Tether (Test) - precision: 0.01 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: test-ethereum - token: true - tokenAddress: 0x110a13FC3efE6A245B50102D2d79B3E76125Ae83 - tokenName: Tether USD - withdrawFee: 0.01 - withdrawMin: 0.01 - decimal: 6 - gift: 1000000 - - symbol: BUSD - name: Binance USD (Test) - precision: 0.01 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: test-bsc - token: true - tokenAddress: 0xeD24FC36d5Ee211Ea25A80239Fb8C4Cfd80f12Ee - tokenName: BUSD Token - withdrawFee: 0.01 - withdrawMin: 0.01 - decimal: 18 - gift: 1000000 - - symbol: BNB - name: Binance (Test) - precision: 0.0001 - mainBalance: 10000 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 - implementations: - - chain: test-bsc - withdrawFee: 0.00001 - withdrawMin: 0.000001 - decimal: 18 - - chain: test-bsc - symbol: WBNB - token: true - tokenAddress: 0x5b3e2bc1da86ff6235d9ead4504d598cae77dbcb - tokenName: Wrapped BNB (Test Net) - withdrawFee: 0.01 - withdrawMin: 0.01 - decimal: 18 - gift: 2000 - - symbol: SOL - name: Solana - precision: 0.00001 - mainBalance: 100000 - dailyTotal: 10000 - dailyCount: 1000 - monthlyTotal: 300000 - monthlyCount: 30000 - implementations: - - chain: test-bsc - token: true - tokenAddress: 0x570A5D26f7765Ecb712C0924E4De545B89fD43dF - tokenName: Wrapped Solana - withdrawFee: 0.0001 - withdrawMin: 0.001 - decimal: 18 - gift: 100 - - symbol: DOGE - name: Dogecoin - precision: 0.001 - mainBalance: 10000000 - dailyTotal: 1000000 - dailyCount: 100000 - monthlyTotal: 30000000 - monthlyCount: 3000000 - implementations: - - chain: test-bsc - token: true - tokenAddress: 0xbA2aE424d960c26247Dd6c32edC70B295c744C43 - tokenName: Binance-Peg Dogecoin - withdrawFee: 2 - withdrawMin: 10 - decimal: 8 - gift: 100 - - symbol: TON - name: Toncoin - precision: 0.0001 - mainBalance: 1000000 - dailyTotal: 100000 - dailyCount: 10000 - monthlyTotal: 3000000 - monthlyCount: 300000 - implementations: - - chain: test-ethereum - token: true - tokenAddress: 0x582d872a1b094fc48f5de31d3b73f2d9be47def1 - tokenName: Wrapped Ton Coin - withdrawFee: 0.3 - withdrawMin: 1 - decimal: 9 - gift: 100 -markets: - - leftSide: BTC - rightSide: USDT - aliases: - - key: binance - alias: BTCUSDT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: ETH - rightSide: USDT - aliases: - - key: binance - alias: ETHUSDT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: BTC - rightSide: IRT - aliases: - - key: binance - alias: BTCIRT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: ETH - rightSide: IRT - aliases: - - key: binance - alias: ETHIRT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: USDT - rightSide: IRT - aliases: - - key: binance - alias: USDIRT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: ETH - rightSide: BUSD - aliases: - - key: binance - alias: ETHBUSD - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: BTC - rightSide: BUSD - aliases: - - key: binance - alias: BTCBUSD - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: BNB - rightSide: BUSD - aliases: - - key: binance - alias: BNBBUSD - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: SOL - rightSide: USDT - aliases: - - key: binance - alias: SOLUSDT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: DOGE - rightSide: USDT - aliases: - - key: binance - alias: DOGEUSDT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - leftSide: TON - rightSide: USDT - aliases: - - key: binance - alias: TONUSDT - feeConfigs: - - direction: ASK - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 - - direction: BID - userLevel: "*" - makerFee: 0.01 - takerFee: 0.01 -userLimits: - - owner: 1 - action: withdraw - walletType: main - withdrawFee: 0.0001 - dailyTotal: 1000 - dailyCount: 100 - monthlyTotal: 30000 - monthlyCount: 3000 -userLevels: - - "*" - - "nofee" -system: - walletTitle: system - walletLevel: system -admin: - walletTitle: admin - walletLevel: admin -auth: - whitelist: - enabled: false - file: /whitelist.txt \ No newline at end of file diff --git a/profile/pom.xml b/profile/pom.xml new file mode 100644 index 000000000..ffd3ab564 --- /dev/null +++ b/profile/pom.xml @@ -0,0 +1,113 @@ + + + + core + co.nilin.opex + 1.0.1-beta.7 + + + 4.0.0 + profile + pom + + + 17 + 17 + UTF-8 + + + + profile-core + profile-app + profile-ports/profile-postgres + profile-ports/profile-eventlistener-kafka + profile-ports/profile-submitter-kafka + profile-ports/profile-kyc-proxy + profile-ports/profile-inquiry-proxy + profile-ports/profile-otp-proxy + + + + + + co.nilin.opex.profile + profile-app + ${project.version} + + + co.nilin.opex.profile + profile-core + ${project.version} + + + co.nilin.opex.profile.ports + profile-eventlistener-kafka + ${project.version} + + + co.nilin.opex.profile.ports + profile-submitter-kafka + ${project.version} + + + co.nilin.opex.profile.ports + profile-postgres + ${project.version} + + + co.nilin.opex.admin.gateway + admin-app + ${project.version} + + + co.nilin.opex.utility + logging-handler + ${logging-handler.version} + + + co.nilin.opex.utility + error-handler + ${error-hanlder.version} + + + co.nilin.opex.utility + interceptors + ${interceptor.version} + + + co.nilin.opex.profile.ports + profile-kyc-proxy + ${project.version} + + + co.nilin.opex.profile.ports + profile-inquiry-proxy + ${project.version} + + + co.nilin.opex.profile.ports + profile-otp-proxy + ${project.version} + + + + + + + + com.google.code.gson + gson + 2.10.1 + + + co.nilin.opex + common + + + org.springframework.boot + spring-boot-starter-test + + + \ No newline at end of file diff --git a/profile/profile-app/.gitignore b/profile/profile-app/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-app/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-app/Dockerfile b/profile/profile-app/Dockerfile new file mode 100644 index 000000000..29f0c2703 --- /dev/null +++ b/profile/profile-app/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:17-jdk +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] +HEALTHCHECK --interval=45s --start-period=30s --retries=5 CMD curl -sf 'http://localhost:8080/actuator/health' >/dev/null || exit 1 diff --git a/profile/profile-app/pom.xml b/profile/profile-app/pom.xml new file mode 100644 index 000000000..2b2412044 --- /dev/null +++ b/profile/profile-app/pom.xml @@ -0,0 +1,163 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + + co.nilin.opex.profile + profile-app + profile-app + profile-app + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.springframework.boot + spring-boot-starter-webflux + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + io.projectreactor + reactor-test + test + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + com.nimbusds + nimbus-jose-jwt + 9.22 + + + co.nilin.opex.profile + profile-core + + + co.nilin.opex.profile.ports + profile-postgres + + + co.nilin.opex.profile.ports + profile-eventlistener-kafka + + + co.nilin.opex.profile.ports + profile-submitter-kafka + + + co.nilin.opex.profile.ports + profile-otp-proxy + + + org.springframework + spring-tx + provided + + + org.springframework.cloud + spring-cloud-starter-vault-config + + + co.nilin.opex.utility + error-handler + + + co.nilin.opex.utility + interceptors + + + org.springframework.cloud + spring-cloud-starter-consul-all + + + io.micrometer + micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.springframework.boot + spring-boot-maven-plugin + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + -Xjsr305=strict + + + spring + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + + + + + diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/ProfileApp.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/ProfileApp.kt new file mode 100644 index 000000000..be29efc53 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/ProfileApp.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.profile.app + +import co.nilin.opex.utility.error.EnableOpexErrorHandler +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan + +@SpringBootApplication +@ComponentScan("co.nilin.opex") +@EnableOpexErrorHandler +class ProfileApp + +fun main(args: Array) { + runApplication(*args) +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/AppConfig.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/AppConfig.kt new file mode 100644 index 000000000..0d63422a8 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/AppConfig.kt @@ -0,0 +1,23 @@ +package co.nilin.opex.profile.app.config + +import co.nilin.opex.profile.core.spi.KycLevelUpdatedEventListener +import co.nilin.opex.profile.core.spi.UserCreatedEventListener +import co.nilin.opex.profile.ports.kafka.consumer.KycLevelUpdatedKafkaListener +import co.nilin.opex.profile.ports.kafka.consumer.UserCreatedKafkaListener +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Configuration + + +@Configuration +class AppConfig { + @Autowired + fun configureEventListeners( + useCreatedKafkaListener: UserCreatedKafkaListener, + userCreatedEventListener: UserCreatedEventListener, + kycLevelUpdatedKafkaListener: KycLevelUpdatedKafkaListener, + kycLevelUpdatedEventListener: KycLevelUpdatedEventListener + ) { + useCreatedKafkaListener.addEventListener(userCreatedEventListener) + kycLevelUpdatedKafkaListener.addEventListener(kycLevelUpdatedEventListener) + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/AppDispatchers.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/AppDispatchers.kt new file mode 100644 index 000000000..f549323d4 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/AppDispatchers.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.profile.app.config + +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors + +object AppDispatchers { + val kafkaExecutor = Executors.newSingleThreadExecutor().asCoroutineDispatcher() +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/RestConfig.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/RestConfig.kt new file mode 100644 index 000000000..bd259474f --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/RestConfig.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.profile.app.config + +import co.nilin.opex.utility.interceptors.FormDataWorkaroundFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.format.Formatter +import org.springframework.web.server.WebFilter +import java.util.* + +@Configuration +class RestConfig { + @Bean + fun dateFormatter(): Formatter? { + return object : Formatter { + override fun print(date: Date, locale: Locale): String { + return date.time.toString() + } + + override fun parse(date: String, locale: Locale): Date { + return Date(date.toLong()) + } + } + } + + @Bean + fun formDataWebFilter(): WebFilter { + return FormDataWorkaroundFilter() + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/SecurityConfig.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/SecurityConfig.kt new file mode 100644 index 000000000..cffba82a5 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/SecurityConfig.kt @@ -0,0 +1,63 @@ +package co.nilin.opex.profile.app.config + +import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import co.nilin.opex.profile.app.utils.AudienceValidator +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +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.web.server.SecurityWebFilterChain +import org.springframework.web.reactive.function.client.WebClient + +@EnableWebFluxSecurity +class SecurityConfig { + + @Value("\${app.auth.cert-url}") + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String + + @Bean + fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { + return http.csrf { it.disable() } + .authorizeExchange() { + it.pathMatchers(HttpMethod.GET, "/admin/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") + .pathMatchers("/admin/**").hasAuthority("ROLE_admin") + .pathMatchers(HttpMethod.GET,"/bank-account").permitAll() + .pathMatchers("/bank-account/**").hasAuthority("PERM_bank_account:write") + .pathMatchers("/actuator/**").permitAll() + .anyExchange().authenticated() + } + .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } } + .build() + } + + + @Bean + @Throws(Exception::class) + fun reactiveJwtDecoder(): ReactiveJwtDecoder? { + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/WebClientConfig.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/WebClientConfig.kt new file mode 100644 index 000000000..03bb0ebcb --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/WebClientConfig.kt @@ -0,0 +1,50 @@ +package co.nilin.opex.profile.app.config + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.cloud.client.ServiceInstance +import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer +import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.client.ExchangeStrategies +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class WebClientConfig { + @Bean + @Qualifier("loadBalanced") + fun loadBalancedWebClient(loadBalancerFactory: ReactiveLoadBalancer.Factory): WebClient { + return WebClient.builder() + .filter(ReactorLoadBalancerExchangeFilterFunction(loadBalancerFactory, emptyList())) + .exchangeStrategies( + ExchangeStrategies.builder() + .codecs { it.defaultCodecs().maxInMemorySize(20 * 1024 * 1024) } + .build() + ) + .build() + } + + @Bean + fun webClient(loadBalancerFactory: ReactiveLoadBalancer.Factory): WebClient { + return WebClient.builder() + .filter( + ReactorLoadBalancerExchangeFilterFunction( + loadBalancerFactory, LoadBalancerProperties(), emptyList() + ) + ) + .build() + } + + @Bean + @Qualifier("plainWebClient") + fun plainWebClient(): WebClient { + return WebClient.builder() + .exchangeStrategies( + ExchangeStrategies.builder() + .codecs { it.defaultCodecs().maxInMemorySize(20 * 1024 * 1024) } + .build() + ) + .build() + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/AddressBookController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/AddressBookController.kt new file mode 100644 index 000000000..653dc49b4 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/AddressBookController.kt @@ -0,0 +1,46 @@ +package co.nilin.opex.profile.app.controller + +import co.nilin.opex.profile.app.dto.AddAddressBookItemRequest +import co.nilin.opex.profile.app.dto.AddressBookResponse +import co.nilin.opex.profile.app.service.AddressBookManagement +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/address-book") +class AddressBookController( + val addressBookManagement: AddressBookManagement, +) { + + @PostMapping + suspend fun addAddressBook( + @RequestBody request: AddAddressBookItemRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): AddressBookResponse { + return addressBookManagement.addAddressBook(securityContext.authentication.name, request) + } + + @GetMapping + suspend fun getAddressBook(@CurrentSecurityContext securityContext: SecurityContext): List { + return addressBookManagement.getAllAddressBooks(securityContext.authentication.name) + } + + @DeleteMapping("/{id}") + suspend fun deleteAddressBook( + @PathVariable("id") id: Long, + @CurrentSecurityContext securityContext: SecurityContext + ) { + addressBookManagement.deleteAddressBook(securityContext.authentication.name, id) + } + + @PutMapping("/{id}") + suspend fun updateAddressBook( + @PathVariable("id") id: Long, + @RequestBody request: AddAddressBookItemRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): AddressBookResponse { + return addressBookManagement.updateAddressBook(securityContext.authentication.name, id, request) + } + +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountController.kt new file mode 100644 index 000000000..475125681 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountController.kt @@ -0,0 +1,58 @@ +package co.nilin.opex.profile.app.controller + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.app.dto.AddBankAccountRequest +import co.nilin.opex.profile.app.dto.BankAccountResponse +import co.nilin.opex.profile.app.service.BankAccountManagement +import co.nilin.opex.profile.ports.postgres.utils.RegexPatterns +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/bank-account") +class BankAccountController( + val bankAccountManagement: BankAccountManagement, +) { + + + @PostMapping + suspend fun addBankAccount( + @RequestBody request: AddBankAccountRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): BankAccountResponse { + validateBankAccountParams(request.cardNumber, request.iban) + return bankAccountManagement.addBankAccount(securityContext.authentication.name, request) + } + + @GetMapping + suspend fun getBankAccounts(@CurrentSecurityContext securityContext: SecurityContext): List { + return bankAccountManagement.getBankAccounts(securityContext.authentication.name) + } + + @DeleteMapping("/{id}") + suspend fun deleteBankAccount( + @PathVariable("id") id: Long, + @CurrentSecurityContext securityContext: SecurityContext + ) { + bankAccountManagement.deleteBankAccount(id, securityContext.authentication.name) + } + + @GetMapping("/ownership") + suspend fun isBankAccountOwnedByUser( + @RequestParam cardNumber: String?, + @RequestParam iban: String?, + @CurrentSecurityContext securityContext: SecurityContext + ): Boolean { + validateBankAccountParams(cardNumber, iban) + return bankAccountManagement.isBankAccountOwnedByUser(securityContext.authentication.name, cardNumber, iban) + } + + private fun validateBankAccountParams(cardNumber: String?, iban: String?) { + if ( + (cardNumber.isNullOrBlank() && iban.isNullOrBlank()) || + (!cardNumber.isNullOrBlank() && !iban.isNullOrBlank()) + ) throw OpexError.InvalidRequestBody.exception("Either Card Number or IBAN must be provided") + if (!iban.isNullOrBlank() && !RegexPatterns.IBAN.matches(iban)) throw OpexError.InvalidIban.exception() + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/LimitationController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/LimitationController.kt new file mode 100644 index 000000000..5304804de --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/LimitationController.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.profile.app.controller + +import co.nilin.opex.profile.app.service.LimitationManagement +import co.nilin.opex.profile.core.data.limitation.ActionType +import co.nilin.opex.profile.core.data.limitation.LimitationReason +import co.nilin.opex.profile.core.data.limitation.LimitationResponse +import kotlinx.coroutines.flow.toList +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +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 + +@RestController +@RequestMapping("/v2/profile/limitation") +class LimitationController(private var limitManagement: LimitationManagement) { + + @GetMapping("") + suspend fun getLimitation( + @RequestParam("action") action: ActionType?, + @RequestParam("reason") reason: LimitationReason?, + @CurrentSecurityContext securityContext: SecurityContext + ): LimitationResponse? { + return LimitationResponse( + totalData = limitManagement.getLimitation( + securityContext.authentication.name, + action, + reason, + 0, + 1000 + )?.toList() + ) + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/ProfileAdminController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/ProfileAdminController.kt new file mode 100644 index 000000000..9f04bcc6e --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/ProfileAdminController.kt @@ -0,0 +1,130 @@ +package co.nilin.opex.profile.app.controller + +import co.nilin.opex.profile.app.dto.UpdateApprovalRequestBody +import co.nilin.opex.profile.app.service.ProfileApprovalRequestManagement +import co.nilin.opex.profile.app.service.ProfileManagement +import co.nilin.opex.profile.core.data.limitation.* +import co.nilin.opex.profile.core.data.profile.* +import co.nilin.opex.profile.ports.postgres.imp.LimitationManagementImp +import kotlinx.coroutines.flow.toList +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/admin/profile") + +class ProfileAdminController( + val profileManagement: ProfileManagement, + val profileApprovalRequestManagement: ProfileApprovalRequestManagement, + val limitManagement: LimitationManagementImp +) { + + @PostMapping("") + suspend fun getProfiles(@RequestBody profileRequest: ProfileRequest): List { + return profileManagement.getAllProfiles(profileRequest) + } + + @GetMapping("/{userId}") + suspend fun getProfile(@PathVariable("userId") userId: String): Profile { + return profileManagement.getProfile(userId) + } + + @GetMapping("/history/{userId}") + suspend fun getProfileHistory( + @PathVariable("userId") userId: String, + @RequestParam offset: Int?, @RequestParam limit: Int? + ): List? { + return profileManagement.getHistory(userId, offset ?: 0, limit ?: 10) + } + + // =====================================Approval Requests==================================== + + @PostMapping("/approval-requests") + suspend fun getApprovalRequests(@RequestBody request: ProfileApprovalRequestFilter): List { + return profileApprovalRequestManagement.getApprovalRequests(request) + } + + @GetMapping("/approval-request/{id}") + suspend fun getApprovalRequest(@PathVariable("id") id: Long): ProfileApprovalAdminResponse { + return profileApprovalRequestManagement.getApprovalRequestById(id) + } + + @PutMapping("/approval-request") + suspend fun updateRequestStatus( + @RequestBody changeRequestStatusBody: UpdateApprovalRequestBody, + @CurrentSecurityContext securityContext: SecurityContext + ): ProfileApprovalAdminResponse { + return profileApprovalRequestManagement.changeRequestStatus( + changeRequestStatusBody.id, + securityContext.authentication.name, + changeRequestStatusBody.description, + changeRequestStatusBody.status + ) + } + + //==============================================limitation services================================================= + + + @PostMapping("/limitation") + suspend fun updateLimitation(@RequestBody permissionRequest: UpdateLimitationRequest) { + permissionRequest.reason ?: LimitationReason.Other + limitManagement.updateLimitation(permissionRequest) + } + + @GetMapping("/limitation") + suspend fun getLimitation( + @RequestParam("userId") userId: String?, + @RequestParam("action") action: ActionType?, + @RequestParam("reason") reason: LimitationReason?, + @RequestParam("groupBy") groupBy: String?, + @RequestParam("size") size: Int?, + @RequestParam("offset") offset: Int? + ): LimitationResponse? { + + var res = limitManagement.getLimitation(userId, action, reason, offset ?: 0, size ?: 1000)?.toList() + + return when (groupBy) { + "user" -> LimitationResponse(res?.groupBy { r -> r.userId }) + "action" -> LimitationResponse(res?.groupBy { r -> r.actionType?.name }) + "reason" -> LimitationResponse(res?.groupBy { r -> (r.reason ?: LimitationReason.Other).name }) + else -> { + LimitationResponse(totalData = res) + } + } + + } + + @GetMapping("/limitation/history") + suspend fun getLimitationHistory( + @RequestParam("userId") userId: String?, + @RequestParam("action") action: ActionType?, + @RequestParam("reason") reason: LimitationReason?, + @RequestParam("groupBy") groupBy: String?, + @RequestParam("size") size: Int?, + @RequestParam("offset") offset: Int? + ): LimitationHistoryResponse? { + + var res = limitManagement.getLimitationHistory(userId, action, reason, offset ?: 0, size ?: 1000)?.toList() + return when (groupBy) { + "user" -> LimitationHistoryResponse(res?.groupBy { r -> r.userId }) + "action" -> LimitationHistoryResponse(res?.groupBy { r -> r.actionType?.name }) + "reason" -> LimitationHistoryResponse(res?.groupBy { r -> (r.reason ?: LimitationReason.Other).name }) + else -> { + LimitationHistoryResponse(totalData = res) + } + } + } + + data class ResolveUsersRequest( + val uuids: List + ) + + @PostMapping("/users/resolve") + suspend fun resolveUsers( + @RequestBody request: ResolveUsersRequest + ): Map { + return profileManagement.resolveUsers(request.uuids) + } + +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/ProfileController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/ProfileController.kt new file mode 100644 index 000000000..864a5b680 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/ProfileController.kt @@ -0,0 +1,71 @@ +package co.nilin.opex.profile.app.controller + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.app.dto.ContactUpdateConfirmRequest +import co.nilin.opex.profile.app.dto.ContactUpdateRequest +import co.nilin.opex.profile.app.service.ProfileApprovalRequestManagement +import co.nilin.opex.profile.app.service.ProfileManagement +import co.nilin.opex.profile.core.data.otp.TempOtpResponse +import co.nilin.opex.profile.core.data.profile.CompleteProfileRequest +import co.nilin.opex.profile.core.data.profile.Profile +import co.nilin.opex.profile.core.data.profile.ProfileApprovalUserResponse +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping + +class ProfileController( + val profileManagement: ProfileManagement, + val profileApprovalRequestManagement: ProfileApprovalRequestManagement, +) { + + @GetMapping("/personal-data") + suspend fun getProfile(@CurrentSecurityContext securityContext: SecurityContext): Profile { + return profileManagement.getProfile(securityContext.authentication.name) + } + + @PutMapping("/completion") + suspend fun completeProfile( + @RequestBody completeProfileRequest: CompleteProfileRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): Profile? { + return profileManagement.completeProfile(securityContext.authentication.name, completeProfileRequest) + } + + @PostMapping("/contact/update/otp-request") + suspend fun requestContactUpdate( + @RequestBody request: ContactUpdateRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): TempOtpResponse { + val username = securityContext.authentication.name + return if (!request.email.isNullOrBlank()) { + profileManagement.requestUpdateEmail(username, request.email) + } else if (!request.mobile.isNullOrBlank()) { + profileManagement.requestUpdateMobile(username, request.mobile) + } else { + throw OpexError.BadRequest.exception("Either email or mobile must be provided.") + } + } + + @PostMapping("/contact/update/otp-verification") + suspend fun confirmContactUpdate( + @RequestBody request: ContactUpdateConfirmRequest, + @CurrentSecurityContext securityContext: SecurityContext + ) { + val username = securityContext.authentication.name + if (!request.email.isNullOrBlank()) { + profileManagement.updateEmail(username, request.email, request.otpCode) + } else if (!request.mobile.isNullOrBlank()) { + profileManagement.updateMobile(username, request.mobile, request.otpCode) + } else { + throw OpexError.BadRequest.exception("Either email or mobile must be provided.") + } + } + + @GetMapping("/approval-request") + suspend fun getApprovalRequest(@CurrentSecurityContext securityContext: SecurityContext): ProfileApprovalUserResponse { + return profileApprovalRequestManagement.getApprovalRequestByUserId(securityContext.authentication.name) + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddAddressBookItemRequest.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddAddressBookItemRequest.kt new file mode 100644 index 000000000..6409aba5e --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddAddressBookItemRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.profile.app.dto + +data class AddAddressBookItemRequest( + var name: String, + var address: String, + var addressType: String, +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddBankAccountRequest.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddBankAccountRequest.kt new file mode 100644 index 000000000..4a95e158f --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddBankAccountRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.profile.app.dto + +data class AddBankAccountRequest( + val name: String? = null, + val cardNumber: String? = null, + val iban: String? = null, +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddressBookResponse.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddressBookResponse.kt new file mode 100644 index 000000000..45a83fcc4 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/AddressBookResponse.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.profile.app.dto + +data class AddressBookResponse( + var id: Long? = null, + var name: String, + var address: String, + var addressType: String, +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/BankAccountResponse.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/BankAccountResponse.kt new file mode 100644 index 000000000..99e6cac3e --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/BankAccountResponse.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.profile.app.dto + +import co.nilin.opex.profile.core.data.profile.BankAccountStatus + +data class BankAccountResponse( + var id: Long? = null, + var name: String? = null, + var cardNumber: String? = null, + var iban: String? = null, + var accountNumber: String? = null, + var bank: String? = null, + var status: BankAccountStatus, +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/ContactUpdateConfirmRequest.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/ContactUpdateConfirmRequest.kt new file mode 100644 index 000000000..2f263efe8 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/ContactUpdateConfirmRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.profile.app.dto + +data class ContactUpdateConfirmRequest( + val email: String? = null, + val mobile: String? = null, + val otpCode: String, +) \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/ContactUpdateRequest.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/ContactUpdateRequest.kt new file mode 100644 index 000000000..30a26e587 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/ContactUpdateRequest.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.app.dto + +data class ContactUpdateRequest( + val email: String? = null, + val mobile: String? = null +) \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/UpdateApprovalRequestBody.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/UpdateApprovalRequestBody.kt new file mode 100644 index 000000000..fb9102318 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/UpdateApprovalRequestBody.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.profile.app.dto + +import co.nilin.opex.profile.core.data.profile.ProfileApprovalRequestStatus + +data class UpdateApprovalRequestBody( + val id: Long, + val description: String?, + val status: ProfileApprovalRequestStatus +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/listener/KycLevelUpdatedListener.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/listener/KycLevelUpdatedListener.kt new file mode 100644 index 000000000..5abbd87fd --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/listener/KycLevelUpdatedListener.kt @@ -0,0 +1,37 @@ +package co.nilin.opex.profile.app.listener + +import co.nilin.opex.profile.app.service.ProfileManagement +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import co.nilin.opex.profile.core.spi.KycLevelUpdatedEventListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +@Component +class KycLevelUpdatedListener(val userRegistrationService: ProfileManagement) : KycLevelUpdatedEventListener { + + private val logger = LoggerFactory.getLogger(KycLevelUpdatedListener::class.java) + override fun id(): String { + return "KycLevelUpdatedListener" + } + + override fun onEvent(event: KycLevelUpdatedEvent, + partition: Int, offset: Long, timestamp: Long, eventId: String) { + logger.info("==========================================================================") + logger.info("Incoming UserLevelUpdated event: $event") + logger.info("==========================================================================") + CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + try { + userRegistrationService.updateUserLevel(event.userId, event.kycLevel) + logger.info("User level updated successfully for ${event.userId}") + } catch (ex: Exception) { + logger.error("Failed to update user level for ${event.userId}", ex) + } + } + } + + +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/listener/UserCreatedListener.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/listener/UserCreatedListener.kt new file mode 100644 index 000000000..a22049d02 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/listener/UserCreatedListener.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.profile.app.listener + +import co.nilin.opex.profile.app.service.ProfileManagement +import co.nilin.opex.profile.core.spi.UserCreatedEventListener +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import co.nilin.opex.profile.core.data.event.UserCreatedEvent +import kotlinx.coroutines.* + +@Component +class UserCreatedListener(val userRegistrationService: ProfileManagement) : UserCreatedEventListener { + + private val logger = LoggerFactory.getLogger(UserCreatedListener::class.java) + override fun id(): String { + return "UserCreatedEventListener" + } + + + override fun onEvent(event: UserCreatedEvent, partition: Int, offset: Long, timestamp: Long, eventId: String) { + logger.info("==========================================================================") + logger.info("Incoming UserCreated event: $event") + logger.info("==========================================================================") + CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + try { + userRegistrationService.registerNewUser(event) + logger.info("User created successfully ") + } catch (ex: Exception) { + logger.error("Failed to create new user", ex) + } + } + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/AddressBookManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/AddressBookManagement.kt new file mode 100644 index 000000000..61e47ab31 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/AddressBookManagement.kt @@ -0,0 +1,48 @@ +package co.nilin.opex.profile.app.service + +import co.nilin.opex.profile.app.dto.AddAddressBookItemRequest +import co.nilin.opex.profile.app.dto.AddressBookResponse +import co.nilin.opex.profile.app.utils.toAddressBookResponse +import co.nilin.opex.profile.core.data.profile.AddressBook +import co.nilin.opex.profile.core.spi.AddressBookPersister +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class AddressBookManagement( + private val addressBookPersister: AddressBookPersister, +) { + suspend fun addAddressBook(uuid: String, request: AddAddressBookItemRequest): AddressBookResponse { + addressBookPersister.findSavedAddress(uuid, request.address, request.addressType) + ?.let { return it.toAddressBookResponse() } + return addressBookPersister.save( + AddressBook( + uuid = uuid, + name = request.name, + address = request.address, + addressType = request.addressType, + createDate = LocalDateTime.now(), + ) + ).toAddressBookResponse() + } + + suspend fun getAllAddressBooks(uuid: String): List { + return addressBookPersister.findAll(uuid).map { it.toAddressBookResponse() } + } + + suspend fun updateAddressBook(uuid: String, id: Long, request: AddAddressBookItemRequest): AddressBookResponse { + return addressBookPersister.update( + AddressBook( + id = id, + uuid = uuid, + name = request.name, + address = request.address, + addressType = request.addressType, + ) + ).toAddressBookResponse() + } + + suspend fun deleteAddressBook(uuid: String, id: Long) { + addressBookPersister.delete(uuid, id) + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountManagement.kt new file mode 100644 index 000000000..184ad36f5 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountManagement.kt @@ -0,0 +1,144 @@ +package co.nilin.opex.profile.app.service + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.app.dto.AddBankAccountRequest +import co.nilin.opex.profile.app.dto.BankAccountResponse +import co.nilin.opex.profile.app.utils.toBankAccountResponse +import co.nilin.opex.profile.core.data.inquiry.IbanInfo +import co.nilin.opex.profile.core.data.profile.BankAccount +import co.nilin.opex.profile.core.data.profile.BankAccountStatus +import co.nilin.opex.profile.core.data.profile.NationalityType +import co.nilin.opex.profile.core.spi.BankAccountPersister +import co.nilin.opex.profile.core.spi.InquiryProxy +import co.nilin.opex.profile.core.spi.ProfilePersister +import co.nilin.opex.profile.core.utils.handleCardIbanInfoError +import co.nilin.opex.profile.core.utils.handleCardOwnershipError +import co.nilin.opex.profile.core.utils.handleIbanInfoError +import co.nilin.opex.profile.core.utils.handleIbanOwnershipError +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class BankAccountManagement( + private val bankAccountPersister: BankAccountPersister, + private val inquiryProxy: InquiryProxy, + private val profilePersister: ProfilePersister, + @Value("\${app.admin-approval.bank-account}") + private var isAdminApprovalRequired: Boolean, +) { + + suspend fun addBankAccount(uuid: String, request: AddBankAccountRequest): BankAccountResponse { + validateBankAccountOwnership(uuid, request.cardNumber, request.iban) + val profile = profilePersister.getProfile(uuid) + + var cardNumber: String? = request.cardNumber + var iban: String? = request.iban + var accountNumber: String? = null + var bank: String? = null + var status: BankAccountStatus? = null + + if (!isAdminApprovalRequired && profile.nationality!! == (NationalityType.IRANIAN)) { + if (!request.cardNumber.isNullOrBlank()) { + verifyCardOwnership(request.cardNumber, profile.identifier!!, profile.birthDate!!) + val ibanInfo = getIbanInfoByCardNumber(request.cardNumber) + + iban = ibanInfo.iban + accountNumber = ibanInfo.depositNumber + bank = ibanInfo.bank + + + } else if (!request.iban.isNullOrBlank()) { + + verifyIbanOwnership(request.iban, profile.identifier!!, profile.birthDate!!) + val ibanInfo = getIbanInfoByIban(request.iban) + accountNumber = ibanInfo.depositNumber + bank = ibanInfo.bank + + } else throw OpexError.InvalidRequestBody.exception("Either Card Number or IBAN must be provided") + status = BankAccountStatus.VERIFIED + } + val bankAccount = BankAccount( + uuid = uuid, + name = request.name, + cardNumber = cardNumber, + iban = iban, + accountNumber = accountNumber, + bank = bank, + status = status ?: BankAccountStatus.WAITING, + createDate = LocalDateTime.now(), + creator = "system" + ) + + return bankAccountPersister.save(bankAccount).toBankAccountResponse() + } + + suspend fun getBankAccounts(uuid: String): List { + return bankAccountPersister.findAll(uuid) + .map { it.toBankAccountResponse() } + } + + suspend fun deleteBankAccount(id: Long, uuid: String) { + bankAccountPersister.delete(id, uuid) + } + + suspend fun isBankAccountOwnedByUser(uuid: String, cardNumber: String?, iban: String?): Boolean { + return !bankAccountPersister.findAll(uuid, cardNumber, iban).isEmpty() + } + + private suspend fun verifyCardOwnership( + cardNumber: String, + nationalCode: String, + birthDate: LocalDateTime + ) { + val ownershipResponse = + inquiryProxy.verifyCardOwnership(cardNumber, nationalCode, birthDate) + if (ownershipResponse.isError()) handleCardOwnershipError(ownershipResponse.code) + if (ownershipResponse.matched == false) throw OpexError.CardOwnershipMismatch.exception() + } + + private suspend fun verifyIbanOwnership( + iban: String, + nationalCode: String, + birthDate: LocalDateTime + ) { + val ownershipResponse = + inquiryProxy.verifyIbanOwnership(iban, nationalCode, birthDate) + if (ownershipResponse.isError()) handleIbanOwnershipError(ownershipResponse.code) + if (ownershipResponse.matched == false) throw OpexError.IbanOwnershipMismatch.exception() + } + + private suspend fun getIbanInfoByCardNumber(cardNumber: String): IbanInfo { + val ibanInfoResponse = inquiryProxy.getIbanInfoByCardNumber(cardNumber) + if (ibanInfoResponse.isError()) handleCardIbanInfoError(ibanInfoResponse.code) + return ibanInfoResponse.ibanInfo ?: throw OpexError.CardIbanInfoInquiryError.exception() + } + + private suspend fun getIbanInfoByIban(iban: String): IbanInfo { + val ibanInfoResponse = inquiryProxy.getIbanInfoByIban(iban) + if (ibanInfoResponse.isError()) handleIbanInfoError(ibanInfoResponse.code) + return ibanInfoResponse.ibanInfo ?: throw OpexError.IbanInfoInquiryError.exception() + } + + private suspend fun validateBankAccountOwnership( + uuid: String, + cardNumber: String?, + iban: String? + ) { + val bankAccounts = bankAccountPersister.findAll(cardNumber, iban) + if (bankAccounts.isEmpty()) return + + if (bankAccounts.any { it.uuid == uuid && it.status != BankAccountStatus.REJECTED }) { + throw OpexError.BankAccountAlreadyExist.exception() + } + + if (bankAccounts.any { it.uuid != uuid && it.status == BankAccountStatus.VERIFIED }) { + throw when { + !cardNumber.isNullOrBlank() -> OpexError.CardOwnershipMismatch.exception() + !iban.isNullOrBlank() -> OpexError.IbanOwnershipMismatch.exception() + else -> OpexError.InvalidRequestBody.exception() + } + } + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/LimitationManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/LimitationManagement.kt new file mode 100644 index 000000000..57bfc9758 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/LimitationManagement.kt @@ -0,0 +1,23 @@ +package co.nilin.opex.profile.app.service + +import co.nilin.opex.profile.core.data.limitation.* +import co.nilin.opex.profile.core.spi.LimitationPersister +import kotlinx.coroutines.flow.Flow +import org.springframework.stereotype.Component + +@Component +class LimitationManagement(private var limitationPersister: LimitationPersister) { + suspend fun updateLimitation(permissionControlRequest: UpdateLimitationRequest) { + limitationPersister.updateLimitation(permissionControlRequest) + + } + + suspend fun getLimitation(userId: String?, action: ActionType?, reason: LimitationReason?, offset: Int, size: Int): Flow? { + return limitationPersister.getLimitation(userId, action, reason, offset, size) + } + + suspend fun getLimitationHistory(userId: String?, action: ActionType?, reason: LimitationReason?, offset: Int, size: Int): Flow? { + return limitationPersister.getLimitationHistory(userId, action, reason, offset, size) + } + +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileApprovalRequestManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileApprovalRequestManagement.kt new file mode 100644 index 000000000..87cd1f6b1 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileApprovalRequestManagement.kt @@ -0,0 +1,74 @@ +package co.nilin.opex.profile.app.service + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent +import co.nilin.opex.profile.core.data.kyc.KycLevel +import co.nilin.opex.profile.core.data.profile.* +import co.nilin.opex.profile.core.spi.KycLevelUpdatedPublisher +import co.nilin.opex.profile.core.spi.ProfileApprovalRequestPersister +import co.nilin.opex.profile.core.spi.ProfilePersister +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class ProfileApprovalRequestManagement( + private val profileApprovalRequestPersister: ProfileApprovalRequestPersister, + private val kycLevelUpdatedPublisher: KycLevelUpdatedPublisher, + private val profilePersister: ProfilePersister, +) { + + suspend fun getApprovalRequests(request: ProfileApprovalRequestFilter): List { + return profileApprovalRequestPersister.getRequests(request) + } + + suspend fun getApprovalRequestById(id: Long): ProfileApprovalAdminResponse { + return profileApprovalRequestPersister.getRequestById(id) + } + + suspend fun getApprovalRequestByUserId(userId: String): ProfileApprovalUserResponse { + return profileApprovalRequestPersister.getRequestByUserId(userId) + } + + suspend fun changeRequestStatus( + id: Long, + updater: String, + description: String?, + status: ProfileApprovalRequestStatus + ): ProfileApprovalAdminResponse { + val approvalRequest = profileApprovalRequestPersister.getRequestById(id) + + if (approvalRequest.status != ProfileApprovalRequestStatus.PENDING || status == ProfileApprovalRequestStatus.PENDING) { + throw OpexError.InvalidProfileApprovalRequestStatus.exception() + } + + approvalRequest.status = status + approvalRequest.updateDate = LocalDateTime.now() + approvalRequest.updater = updater + approvalRequest.description = description + + val response = profileApprovalRequestPersister.update(approvalRequest) + + when (status) { + ProfileApprovalRequestStatus.APPROVED -> { + kycLevelUpdatedPublisher.publish( + KycLevelUpdatedEvent( + approvalRequest.userId, + KycLevel.LEVEL_2, + LocalDateTime.now() + ) + ) + profilePersister.updateStatus(approvalRequest.userId, ProfileStatus.ADMIN_APPROVED) + } + + ProfileApprovalRequestStatus.REJECTED -> { + profilePersister.updateStatus(approvalRequest.userId, ProfileStatus.ADMIN_REJECTED) + } + + else -> { + throw OpexError.InvalidProfileApprovalRequestStatus.exception() + } + } + + return response + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileManagement.kt new file mode 100644 index 000000000..2d429022c --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileManagement.kt @@ -0,0 +1,246 @@ +package co.nilin.opex.profile.app.service + + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent +import co.nilin.opex.profile.core.data.event.ProfileUpdatedEvent +import co.nilin.opex.profile.core.data.event.UserCreatedEvent +import co.nilin.opex.profile.core.data.inquiry.ComparativeResponse +import co.nilin.opex.profile.core.data.inquiry.ShahkarResponse +import co.nilin.opex.profile.core.data.kyc.KycLevel +import co.nilin.opex.profile.core.data.otp.* +import co.nilin.opex.profile.core.data.profile.* +import co.nilin.opex.profile.core.spi.* +import co.nilin.opex.profile.core.utils.handleComparativeError +import co.nilin.opex.profile.core.utils.handleShahkarError +import co.nilin.opex.utility.error.data.OpexException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.reactive.awaitFirst +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class ProfileManagement( + private val profilePersister: ProfilePersister, private val limitationPersister: LimitationPersister, + private val profileApprovalRequestPersister: ProfileApprovalRequestPersister, + private val kycLevelUpdatedPublisher: KycLevelUpdatedPublisher, + private val profileUpdatedPublisher: ProfileUpdatedPublisher, + private val otpProxy: OtpProxy, + private val inquiryProxy: InquiryProxy, + + @Value("\${app.inquiry.mobile-identity}") + private var mobileIdentityEnabled: Boolean, + + @Value("\${app.inquiry.personal-identity}") + private var personalIdentityEnabled: Boolean, + + @Value("\${app.admin-approval.profile-completion-request}") + private var isAdminApprovalRequired: Boolean, +) { + private val logger = LoggerFactory.getLogger(ProfileManagement::class.java) + suspend fun registerNewUser(event: UserCreatedEvent) { + with(event) { + profilePersister.createProfile( + Profile( + firstName = firstName, + lastName = lastName, + email = email, + mobile = mobile, + userId = uuid, + status = ProfileStatus.CREATED, + createDate = LocalDateTime.now(), + lastUpdateDate = LocalDateTime.now(), + creator = "system", + kycLevel = KycLevel.LEVEL_1 + ) + ) + } + } + + suspend fun getAllProfiles(profileRequest: ProfileRequest): List { + return profilePersister.getAllProfile(profileRequest) + } + + suspend fun getProfile(userId: String): Profile { + return profilePersister.getProfile(userId) + } + + suspend fun getHistory(userId: String, offset: Int, limit: Int): List? { + return profilePersister.getHistory(userId, offset, limit) + } + + suspend fun updateUserLevel(userId: String, userLevel: KycLevel) { + profilePersister.updateUserLevelAndStatus(userId, userLevel, true) + } + + suspend fun requestUpdateMobile(userId: String, mobile: String): TempOtpResponse { + profilePersister.validateMobileForUpdate(userId, mobile) + return otpProxy.requestOtp( + NewOTPRequest( + mobile, + listOf(OTPReceiver(mobile, OTPType.SMS)), + OTPAction.UPDATE_MOBILE.name + ) + ).apply { otpReceiver = OTPReceiver(mobile, OTPType.SMS) } + } + + suspend fun updateMobile(userId: String, mobile: String, otpCode: String) { + val verifyResponse = otpProxy.verifyOtp( + VerifyOTPRequest( + mobile, + listOf(OTPCode(OTPType.SMS, otpCode)) + ) + ) + if (verifyResponse.result) { + profileUpdatedPublisher.publish(ProfileUpdatedEvent(userId = userId, mobile = mobile)) + profilePersister.updateMobile(userId, mobile) + } else throw OpexError.InvalidOTP.exception() + } + + suspend fun requestUpdateEmail(userId: String, email: String): TempOtpResponse { + profilePersister.validateEmailForUpdate(userId, email) + return otpProxy.requestOtp( + NewOTPRequest( + email, + listOf(OTPReceiver(email, OTPType.EMAIL)), + OTPAction.UPDATE_EMAIL.name + ) + ).apply { otpReceiver = OTPReceiver(email, OTPType.EMAIL) } + } + + suspend fun updateEmail(userId: String, email: String, otpCode: String) { + val verifyResponse = otpProxy.verifyOtp( + VerifyOTPRequest( + email, + listOf(OTPCode(OTPType.EMAIL, otpCode)) + ) + ) + if (verifyResponse.result) { + profileUpdatedPublisher.publish(ProfileUpdatedEvent(userId = userId, email = email)) + profilePersister.updateEmail(userId, email) + } else throw OpexError.InvalidOTP.exception() + } + + suspend fun completeProfile(userId: String, request: CompleteProfileRequest): Profile { + val profile = profilePersister.getProfile(userId) + + if (profile.kycLevel == KycLevel.LEVEL_2) { + throw OpexError.ProfileAlreadyCompleted.exception() + } + + val isIranian = request.nationality == NationalityType.IRANIAN + + val shahkarResponse = if (isIranian && mobileIdentityEnabled) { + inquiryProxy.getShahkarInquiryResult(request.identifier, profile.mobile!!) + } else null + + val comparativeResponse = if (isIranian && personalIdentityEnabled) { + inquiryProxy.getComparativeInquiryResult( + request.identifier, + request.birthDate, + request.firstName, + request.lastName + ) + } else null + + val isMobileIdentityMatch = shahkarResponse?.let { + !it.isError() && it.matched == true + } + + val isPersonalIdentityMatch = comparativeResponse?.let { + !it.isError() && + (it.firstNameSimilarityPercentage ?: 0) >= 95 && + (it.lastNameSimilarityPercentage ?: 0) >= 95 + } + + val completedProfile = updateProfile(userId, request, isMobileIdentityMatch, isPersonalIdentityMatch) + + profileUpdatedPublisher.publish( + ProfileUpdatedEvent( + userId = userId, + firstName = request.firstName, + lastName = request.lastName, + identifier = request.identifier + ) + ) + + try { + validateInquiryResponses(shahkarResponse, comparativeResponse) + } catch (e: OpexException) { + if (!isAdminApprovalRequired || + (e.error != OpexError.ShahkarInquiryError && e.error != OpexError.ComparativeInquiryError) + ) throw e + } + + val shahkarRequested = isIranian && mobileIdentityEnabled + val comparativeRequested = isIranian && personalIdentityEnabled + + val shahkarOk = !shahkarRequested || (shahkarResponse != null && !shahkarResponse.isError()) + val comparativeOk = !comparativeRequested || (comparativeResponse != null && !comparativeResponse.isError()) + + val allRequestedInquiriesOk = shahkarOk && comparativeOk + val anyInquiryRequested = shahkarRequested || comparativeRequested + + return when { + !isAdminApprovalRequired -> approveProfileAutomatically(userId, completedProfile) + isAdminApprovalRequired && !anyInquiryRequested -> requestAdminApproval(userId) + isAdminApprovalRequired && allRequestedInquiriesOk -> approveProfileAutomatically(userId, completedProfile) + else -> requestAdminApproval(userId) // At least one active inquiry has failed. + } + } + + private suspend fun approveProfileAutomatically(userId: String, completedProfile: Profile): Profile { + kycLevelUpdatedPublisher.publish( + KycLevelUpdatedEvent(userId, KycLevel.LEVEL_2, LocalDateTime.now()) + ) + return profilePersister.updateStatus(userId, ProfileStatus.SYSTEM_APPROVED) + } + + private suspend fun requestAdminApproval(userId: String): Profile { + saveProfileApprovalRequest(userId) + return profilePersister.updateStatus(userId, ProfileStatus.PENDING_ADMIN_APPROVAL) + } + + suspend fun updateProfile( + userId: String, + request: CompleteProfileRequest, + isMobileIdentityMatch: Boolean?, + isPersonalIdentityMatch: Boolean? + ): Profile = coroutineScope { + profilePersister.completeProfile(userId, request, isMobileIdentityMatch, isPersonalIdentityMatch).awaitFirst() + } + + private fun validateInquiryResponses( + shahkarResponse: ShahkarResponse?, + comparativeResponse: ComparativeResponse? + ) { + shahkarResponse?.let { + if (it.isError()) handleShahkarError(it.code) + if (it.matched == false) throw OpexError.ShahkarVerificationFailed.exception() + } + + comparativeResponse?.let { + if (it.isError()) handleComparativeError(it.code) + if ((it.firstNameSimilarityPercentage ?: 0) < 95) + throw OpexError.FirstNameIsNotSimilarEnough.exception() + if ((it.lastNameSimilarityPercentage ?: 0) < 95) + throw OpexError.LastNameIsNotSimilarEnough.exception() + } + } + + private suspend fun saveProfileApprovalRequest(userId: String) { + profileApprovalRequestPersister.save( + ProfileApprovalRequest( + userId = userId, + status = ProfileApprovalRequestStatus.PENDING, + createDate = LocalDateTime.now() + ) + ) + } + + suspend fun resolveUsers(request: List): Map { + return profilePersister.resolveUsers(request) + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/AudienceValidator.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..2cd1b0038 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.profile.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/Convertor.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/Convertor.kt new file mode 100644 index 000000000..cb6e93425 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/Convertor.kt @@ -0,0 +1,23 @@ +package co.nilin.opex.profile.app.utils + +import co.nilin.opex.profile.app.dto.AddressBookResponse +import co.nilin.opex.profile.app.dto.BankAccountResponse +import co.nilin.opex.profile.core.data.profile.AddressBook +import co.nilin.opex.profile.core.data.profile.BankAccount + +fun BankAccount.toBankAccountResponse() = BankAccountResponse( + id = this.id, + name = this.name, + cardNumber = this.cardNumber, + iban = this.iban, + accountNumber = this.accountNumber, + bank = this.bank, + status = this.status +) + +fun AddressBook.toAddressBookResponse() = AddressBookResponse( + id = this.id, + name = this.name, + address = this.address, + addressType = this.addressType, +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/Extensions.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/Extensions.kt new file mode 100644 index 000000000..49cc02cc3 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/Extensions.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.profile.app.utils + + +import com.nimbusds.jose.shaded.json.JSONArray +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.jwt.Jwt + +fun ServerHttpSecurity.AuthorizeExchangeSpec.Access.hasRole( + authority: String, + role: String +): ServerHttpSecurity.AuthorizeExchangeSpec = access { mono, _ -> + mono.map { auth -> + val hasAuthority = auth.authorities.any { it.authority == authority } + val hasRole = ((auth.principal as Jwt).claims["roles"] as JSONArray?)?.contains(role) == true + AuthorizationDecision(hasAuthority && hasRole) + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/PrometheusHealthExtension.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/PrometheusHealthExtension.kt new file mode 100644 index 000000000..c6c017a8a --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/PrometheusHealthExtension.kt @@ -0,0 +1,63 @@ +package co.nilin.opex.profile.app.utils + +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.boot.actuate.health.HealthComponent +import org.springframework.boot.actuate.health.HealthEndpoint +import org.springframework.boot.actuate.health.SystemHealth +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class PrometheusHealthExtension( + private val registry: MeterRegistry, + private val endpoint: HealthEndpoint +) { + + private var consulHealth = -1 + private var r2dbcHealth = -1 + private var vaultHealth = -1 + private var vaultReactiveHealth = -1 + private val service = "PROFILE" + + init { + Gauge.builder("consul_health", consulHealth) { consulHealth.toDouble() } + .description("Health of consul connection") + .tag("Service", service) + .register(registry) + + Gauge.builder("r2dbc_health", r2dbcHealth) { r2dbcHealth.toDouble() } + .description("Health of r2dbc connection") + .tag("Service", service) + .register(registry) + + Gauge.builder("vault_health", vaultHealth) { vaultHealth.toDouble() } + .description("Health of vault connection") + .tag("Service", service) + .register(registry) + + Gauge.builder("vaultReactive_health", vaultReactiveHealth) { vaultReactiveHealth.toDouble() } + .description("Health of vaultReactive connection") + .tag("Service", service) + .register(registry) + } + + @Scheduled(initialDelay = 1000, fixedDelay = 5000) + fun updateHealth() { + try { + val health = endpoint.health() as SystemHealth + consulHealth = getHealthValue(health.components["consul"]) + r2dbcHealth = getHealthValue(health.components["r2dbc"]) + vaultHealth = getHealthValue(health.components["vault"]) + vaultReactiveHealth = getHealthValue(health.components["vaultReactive"]) + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun getHealthValue(health: HealthComponent?): Int { + health ?: return -1 + return if (health.status.code == "UP") 1 else 0 + } + +} \ No newline at end of file diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/VaultUserIdMechanism.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/VaultUserIdMechanism.kt new file mode 100644 index 000000000..d5d7523c9 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/VaultUserIdMechanism.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.profile.app.utils + +import org.springframework.vault.authentication.AppIdUserIdMechanism + +class VaultUserIdMechanism : AppIdUserIdMechanism { + override fun createUserId(): String { + return System.getenv("BACKEND_USER") + } +} \ No newline at end of file diff --git a/profile/profile-app/src/main/resources/application.yml b/profile/profile-app/src/main/resources/application.yml new file mode 100644 index 000000000..bdcdb5251 --- /dev/null +++ b/profile/profile-app/src/main/resources/application.yml @@ -0,0 +1,90 @@ +server.port: 8080 +logging: + level: + co.nilin: DEBUG + reactor.netty.http.client: DEBUG +spring: + application: + name: opex-profile + main: + allow-bean-definition-overriding: false + allow-circular-references: true + kafka: + bootstrap-servers: ${KAFKA_IP_PORT:localhost:9092} + consumer: + group-id: profile + r2dbc: + url: r2dbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} + initialization-mode: always + datasource: + url: jdbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} + cloud: + bootstrap: + enabled: true + vault: + host: ${VAULT_HOST} + port: 8200 + scheme: http + authentication: APPID + app-id: + user-id: co.nilin.opex.profile.app.utils.VaultUserIdMechanism + fail-fast: true + kv: + enabled: true + backend: secret + profile-separator: '/' + application-name: ${spring.application.name} + consul: + host: ${CONSUL_HOST:localhost} + port: 8500 + discovery: + #healthCheckPath: ${management.context-path}/health + instance-id: ${spring.application.name}:${server.port} + healthCheckInterval: 20s + prefer-ip-address: true + config: + import: vault://secret/${spring.application.name} +swagger.authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token +app: + admin-approval: + profile-completion-request: ${ADMIN_APPROVAL_PROFILE_COMPLETION_REQUEST:false} + bank-account: ${ADMIN_APPROVAL_BANK_ACCOUNT:false} + auth: + cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} + kyc: + url: lb://opex-kyc/v2/admin/kyc/internal + otp: + url: lb://opex-otp/v1 + auth-gateway: + url: lb://opex-auth-gateway + inquiry: + mobile-identity: ${MOBILE_IDENTITY_INQUIRY} + personal-identity: ${PERSONAL_IDENTITY_INQUIRY} + url: ${JIBIT_URL} + api-key: ${JIBIT_API_KEY} + secret-key: ${JIBIT_SECRET_KEY} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} + custom-user-language: + enabled: ${CUSTOM_USER_LANGUAGE_ENABLED:false} + +management: + endpoints: + web: + base-path: /actuator + exposure: + include: [ "health", "prometheus", "metrics" ] + endpoint: + health: + show-details: when_authorized + metrics: + enabled: true + prometheus: + enabled: true + diff --git a/profile/profile-app/src/test/kotlin/co/nilin/opex/profile/app/ProfileAppApplicationTests.kt b/profile/profile-app/src/test/kotlin/co/nilin/opex/profile/app/ProfileAppApplicationTests.kt new file mode 100644 index 000000000..abcbcd7fa --- /dev/null +++ b/profile/profile-app/src/test/kotlin/co/nilin/opex/profile/app/ProfileAppApplicationTests.kt @@ -0,0 +1,12 @@ +//package co.nilin.opex.profile.app +// +//import org.junit.jupiter.api.Test +//import org.springframework.boot.test.context.SpringBootTest +//import java.time.LocalDate +//import java.time.LocalDateTime +// +// +//class ProfileAppApplicationTests { +// +// +//} diff --git a/profile/profile-core/.gitignore b/profile/profile-core/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-core/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-core/pom.xml b/profile/profile-core/pom.xml new file mode 100644 index 000000000..794519145 --- /dev/null +++ b/profile/profile-core/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + + co.nilin.opex.profile + profile-core + profile-core + profile-core + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.springframework.boot + spring-boot-starter-webflux + + + io.projectreactor.kotlin + reactor-kotlin-extensions + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + + diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/KycLevelUpdatedEvent.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/KycLevelUpdatedEvent.kt new file mode 100644 index 000000000..d22aae3e8 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/KycLevelUpdatedEvent.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.event + +import co.nilin.opex.profile.core.data.kyc.KycLevel +import java.time.LocalDateTime + +data class KycLevelUpdatedEvent(var userId: String, var kycLevel: KycLevel, var updateDate: LocalDateTime) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/ProfileUpdatedEvent.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/ProfileUpdatedEvent.kt new file mode 100644 index 000000000..ad7e6394f --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/ProfileUpdatedEvent.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.core.data.event + +data class ProfileUpdatedEvent( + var userId: String, + var firstName: String? = null, + var lastName: String? = null, + var email: String? = null, + var mobile: String? = null, + var identifier: String? = null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/UserCreatedEvent.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/UserCreatedEvent.kt new file mode 100644 index 000000000..0b4415049 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/event/UserCreatedEvent.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.profile.core.data.event + +import java.time.LocalDateTime + +class UserCreatedEvent { + var eventDate: LocalDateTime = LocalDateTime.now() + lateinit var uuid: String + lateinit var username: String + var firstName: String? = null + var lastName: String? = null + var email: String? = null + var mobile: String? = null + + + constructor(uuid: String, firstName: String?, lastName: String?, email: String?, mobile: String?) : super() { + this.uuid = uuid + this.firstName = firstName + this.lastName = lastName + this.email = email + this.mobile = mobile + } + + constructor() : super() + + override fun toString(): String { + return "UserCreatedEvent(uuid='$uuid', firstName='$firstName', lastName='$lastName', email='$email' , mobile='$mobile')" + } +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/ComparativeResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/ComparativeResponse.kt new file mode 100644 index 000000000..61cd3afb8 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/ComparativeResponse.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.core.data.inquiry + +data class ComparativeResponse( + val firstNameSimilarityPercentage: Int? = null, + val lastNameSimilarityPercentage: Int? = null, + val code: String? = null, + val message: String? = null +) { + fun isError() = !code.isNullOrEmpty() +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/IbanInfoResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/IbanInfoResponse.kt new file mode 100644 index 000000000..a0830a033 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/IbanInfoResponse.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.profile.core.data.inquiry + +data class IbanInfoResponse( + val ibanInfo: IbanInfo? = null, + val code: String? = null, + val message: String? = null +) { + fun isError() = !code.isNullOrEmpty() +} + +data class IbanInfo( + val bank: String, + val depositNumber: String, + val iban: String, +) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/ShahkarResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/ShahkarResponse.kt new file mode 100644 index 000000000..fb9e57eb8 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/ShahkarResponse.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.profile.core.data.inquiry + +data class ShahkarResponse( + val matched: Boolean? = null, + val code: String? = null, + val message: String? = null +) { + fun isError() = !code.isNullOrEmpty() +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/VerifyOwnershipResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/VerifyOwnershipResponse.kt new file mode 100644 index 000000000..503648086 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/inquiry/VerifyOwnershipResponse.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.profile.core.data.inquiry + +data class VerifyOwnershipResponse( + val matched: Boolean? = null, + val code: String? = null, + val message: String? = null +) { + fun isError() = !code.isNullOrEmpty() +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycLevel.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycLevel.kt new file mode 100644 index 000000000..3ebc07327 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycLevel.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.kyc + +enum class KycLevel { + LEVEL_1, LEVEL_2, LEVEL_3 +} + diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycLevelDetail.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycLevelDetail.kt new file mode 100644 index 000000000..1aa95e0ec --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycLevelDetail.kt @@ -0,0 +1,26 @@ +package co.nilin.opex.profile.core.data.kyc + +enum class KycLevelDetail(val kycLevel: KycLevel) { + Registered(KycLevel.LEVEL_1), + ProfileCompleted(KycLevel.LEVEL_2), + UploadDataLevel3(KycLevel.LEVEL_2), + AcceptedManualReview(KycLevel.LEVEL_3), + RejectedManualReview(KycLevel.LEVEL_2), + ManualUpdateLevel1(KycLevel.LEVEL_1), + ManualUpdateLevel2(KycLevel.LEVEL_2), + ManualUpdateLevel3(KycLevel.LEVEL_3); + + + public val previousValidSteps: List? + get() = when (this) { + Registered -> null + ProfileCompleted -> arrayOf(Registered).asList() + UploadDataLevel3 -> arrayOf(Registered, RejectedManualReview, ManualUpdateLevel1, ManualUpdateLevel3,ProfileCompleted).asList() + AcceptedManualReview -> arrayOf(UploadDataLevel3, RejectedManualReview, ManualUpdateLevel1,ManualUpdateLevel2, ManualUpdateLevel3).asList() + RejectedManualReview -> arrayOf(UploadDataLevel3, AcceptedManualReview, ManualUpdateLevel1,ManualUpdateLevel2, ManualUpdateLevel3).asList() + else -> { + null + } + } + +} diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycRequest.kt new file mode 100644 index 000000000..9e8950f5c --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycRequest.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.profile.core.data.kyc + +import java.time.LocalDateTime + +open class KycRequest { + lateinit var userId: String + var stepId: String? = null + var referenceId: String? = null + var issuer: String? = null + var step: KycStep? = null + var createDate: LocalDateTime? = LocalDateTime.now() + var description: String? = null + var method: KycMethod? = null +} diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycStep.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycStep.kt new file mode 100644 index 000000000..f0341b176 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/KycStep.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.profile.core.data.kyc + + +enum class KycStep { + UploadDataForLevel3(), ManualReview(), Register(), ManualUpdate() , ProfileCompleted() +} + +enum class KycStatus { + Successful, Failed, Rejected, Accepted +} + +enum class KycMethod{ + METHOD_1 , METHOD_2 +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/ManualUpdateRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/ManualUpdateRequest.kt new file mode 100644 index 000000000..c2341f3bc --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/kyc/ManualUpdateRequest.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.kyc + +data class ManualUpdateRequest( + var level: KycLevelDetail, +) : KycRequest() diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/ActionType.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/ActionType.kt new file mode 100644 index 000000000..71a56ec66 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/ActionType.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.limitation + +enum class ActionType { + Login, Buy, Sell, Withdraw, CashOut, All, Deposit +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/Limitation.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/Limitation.kt new file mode 100644 index 000000000..e121b53dd --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/Limitation.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.profile.core.data.limitation + +import com.fasterxml.jackson.annotation.JsonInclude +import java.time.LocalDateTime + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class Limitation( + var expTime: LocalDateTime?, + var userId: String?, + var actionType: ActionType?, + var createDate: LocalDateTime?, + var detail: String?, + var description: String?, + var reason: LimitationReason? +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationHistory.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationHistory.kt new file mode 100644 index 000000000..bf5778069 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationHistory.kt @@ -0,0 +1,19 @@ +package co.nilin.opex.profile.core.data.limitation + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import java.time.LocalDateTime + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class LimitationHistory( + var expTime: LocalDateTime?, + var userId: String?, + var actionType: ActionType?, + var createDate: LocalDateTime?, + var detail: String?, + var description: String?, + var issuer: String?, + var changeRequestDate: LocalDateTime?, + var changeRequestType: String?, + var reason: LimitationReason? +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationHistoryResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationHistoryResponse.kt new file mode 100644 index 000000000..3fe9e4336 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationHistoryResponse.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.profile.core.data.limitation + +import com.fasterxml.jackson.annotation.JsonInclude + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class LimitationHistoryResponse( + var response: Map>? = null, + var totalData: List? = null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationReason.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationReason.kt new file mode 100644 index 000000000..4dc123957 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationReason.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.limitation + +enum class LimitationReason { + MajorProfileChange, ContactProfileChange, Other +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationResponse.kt new file mode 100644 index 000000000..f71c6995d --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationResponse.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.limitation + +import com.fasterxml.jackson.annotation.JsonInclude + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class LimitationResponse(var response: Map>? = null, var totalData: List? = null) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationUpdateType.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationUpdateType.kt new file mode 100644 index 000000000..83dcf3ad5 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/LimitationUpdateType.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.profile.core.data.limitation + +enum class LimitationUpdateType { Revoke, Access } diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/UpdateLimitationRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/UpdateLimitationRequest.kt new file mode 100644 index 000000000..48c28150c --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/limitation/UpdateLimitationRequest.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.profile.core.data.limitation + +data class UpdateLimitationRequest( + var userId: String?, + var actions: List?, + var exprTime: Long?, + var updateType: LimitationUpdateType, + var description: String?, + var detail: String?, + var reason: LimitationReason? +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/NewOTPRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/NewOTPRequest.kt new file mode 100644 index 000000000..ce9a1f99e --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/NewOTPRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.profile.core.data.otp + +data class NewOTPRequest( + val userId: String, + val receivers: List, + val action: String? +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPAction.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPAction.kt new file mode 100644 index 000000000..d24f325e6 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPAction.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.otp + +enum class OTPAction { + UPDATE_MOBILE, UPDATE_EMAIL +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPCode.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPCode.kt new file mode 100644 index 000000000..623f4173b --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPCode.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.otp + +data class OTPCode( + val type: OTPType, + val code: String +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPReceiver.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPReceiver.kt new file mode 100644 index 000000000..55128575f --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPReceiver.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.otp + +data class OTPReceiver( + val receiver: String, + val type: OTPType, +) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPResultType.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPResultType.kt new file mode 100644 index 000000000..6659ef91f --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPResultType.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.otp + +enum class OTPResultType { + VALID, EXPIRED, INCORRECT, INVALID +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPType.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPType.kt new file mode 100644 index 000000000..5ff8c116a --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPType.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.profile.core.data.otp + +enum class OTPType(val compositeOrder: Int) { + + SMS(0), EMAIL(1), + COMPOSITE(99) +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPVerifyResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPVerifyResponse.kt new file mode 100644 index 000000000..984908862 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/OTPVerifyResponse.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.otp + +data class OTPVerifyResponse( + val result: Boolean, + val type: OTPResultType +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/TempOtpResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/TempOtpResponse.kt new file mode 100644 index 000000000..0e5e52077 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/TempOtpResponse.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.profile.core.data.otp + +data class TempOtpResponse(val otp: String?, var otpReceiver: OTPReceiver?) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/VerifyOTPRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/VerifyOTPRequest.kt new file mode 100644 index 000000000..4d9129749 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/otp/VerifyOTPRequest.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.otp + +data class VerifyOTPRequest( + val userId: String, + val otpCodes: List +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/AddressBook.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/AddressBook.kt new file mode 100644 index 000000000..806bd5e03 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/AddressBook.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class AddressBook( + var id: Long? = null, + var uuid: String, + var name: String, + var address: String, + var addressType: String, + var createDate: LocalDateTime? = null, + var updateDate: LocalDateTime? = null +) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccount.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccount.kt new file mode 100644 index 000000000..37d48dc74 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccount.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class BankAccount( + var id: Long? = null, + var uuid: String, + var name: String? = null, + var cardNumber: String? = null, + var iban: String? = null, + var accountNumber: String? = null, + var bank: String? = null, + var status: BankAccountStatus, + var createDate: LocalDateTime, + var updateDate: LocalDateTime? = null, + var creator: String? = null, +) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountStatus.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountStatus.kt new file mode 100644 index 000000000..d527445d9 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountStatus.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.profile + +enum class BankAccountStatus { + WAITING, VERIFIED, REJECTED +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/CompleteProfileRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/CompleteProfileRequest.kt new file mode 100644 index 000000000..397155d15 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/CompleteProfileRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.profile.core.data.profile + +data class CompleteProfileRequest( + var firstName: String, + var lastName: String, + var address: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: NationalityType, + var identifier: String, + var gender: Gender, + var birthDate: Long, +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/Gender.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/Gender.kt new file mode 100644 index 000000000..82d852345 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/Gender.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.profile + +enum class Gender { + FEMALE, MALE +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/NationalityType.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/NationalityType.kt new file mode 100644 index 000000000..6348e4702 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/NationalityType.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.data.profile + +enum class NationalityType { + IRANIAN, + NON_IRANIAN +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/Profile.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/Profile.kt new file mode 100644 index 000000000..06e273f89 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/Profile.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.profile.core.data.profile + +import co.nilin.opex.profile.core.data.kyc.KycLevel +import java.time.LocalDateTime + +data class Profile( + var email: String?, + var userId: String?, + var firstName: String? = null, + var lastName: String? = null, + var address: String? = null, + var mobile: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: NationalityType? = null, + var identifier: String? = null, + var gender: Gender? = null, + var birthDate: LocalDateTime? = null, + var status: ProfileStatus? = null, + var createDate: LocalDateTime? = null, + var lastUpdateDate: LocalDateTime? = null, + var creator: String? = null, + var kycLevel: KycLevel? = null, + var mobileIdentityMatch: Boolean? = null, + var personalIdentityMatch: Boolean? = null + +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalAdminResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalAdminResponse.kt new file mode 100644 index 000000000..cb9447a5a --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalAdminResponse.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class ProfileApprovalAdminResponse( + var id: Long, + var userId: String, + var status: ProfileApprovalRequestStatus, + var createDate: LocalDateTime, + var updateDate: LocalDateTime? = null, + var updater: String? = null, + var description: String? = null, + var firstName: String? = null, + var lastName: String? = null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequest.kt new file mode 100644 index 000000000..2fdac76ff --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequest.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class ProfileApprovalRequest( + var userId: String, + var status: ProfileApprovalRequestStatus? = ProfileApprovalRequestStatus.PENDING, + var createDate: LocalDateTime? = null, + var updateDate: LocalDateTime? = null, + var updater: String? = null, + var description: String?=null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequestFilter.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequestFilter.kt new file mode 100644 index 000000000..70ab011aa --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequestFilter.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.profile.core.data.profile + +data class ProfileApprovalRequestFilter( + val userId: String?, + val status: ProfileApprovalRequestStatus?, + val createDateFrom: Long?, + val createDateTo: Long?, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, + + ) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequestStatus.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequestStatus.kt new file mode 100644 index 000000000..4e30230e4 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalRequestStatus.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.profile + +enum class ProfileApprovalRequestStatus { + PENDING, APPROVED, REJECTED +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalUserResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalUserResponse.kt new file mode 100644 index 000000000..f2dfc6d0a --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileApprovalUserResponse.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class ProfileApprovalUserResponse( + var status: ProfileApprovalRequestStatus, + var createDate: LocalDateTime, + var description: String? = null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileHistory.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileHistory.kt new file mode 100644 index 000000000..f62405796 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileHistory.kt @@ -0,0 +1,30 @@ +package co.nilin.opex.profile.core.data.profile + +import co.nilin.opex.profile.core.data.kyc.KycLevel +import java.time.LocalDateTime + +data class ProfileHistory( + var email: String?, + var userId: String?, + var firstName: String? = null, + var lastName: String? = null, + var address: String? = null, + var mobile: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: String? = null, + var identifier: String? = null, + var gender: Boolean? = null, + var birthDate: LocalDateTime? = null, + var status: ProfileStatus? = null, + var createDate: LocalDateTime? = null, + var lastUpdateDate: LocalDateTime? = null, + var creator: String? = null, + var changeRequestDate: LocalDateTime?, + var changeRequestType: String?, + var updatedItem: List?, + var kycLevel: KycLevel? = null, + var mobileIdentityMatch: Boolean? = null, + var personalIdentityMatch: Boolean? = null + +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileRequest.kt new file mode 100644 index 000000000..3c52c2bfb --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileRequest.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.profile.core.data.profile + +import co.nilin.opex.profile.core.data.kyc.KycLevel + +data class ProfileRequest( + var userId: String? = null, + var firstName: String? = null, + var lastName: String? = null, + var mobile: String? = null, + var email: String? = null, + var identifier: String? = null, + var nationality: NationalityType? = null, + var gender: Gender? = null, + var status: ProfileStatus? = null, + var kycLevel: KycLevel? = null, + var createDateFrom: Long? = null, + var createDateTo: Long? = null, + var limit : Int = 10, + var offset: Int = 0, + var ascendingByTime: Boolean = false, +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileStatus.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileStatus.kt new file mode 100644 index 000000000..1cea68f76 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/ProfileStatus.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.profile.core.data.profile + +enum class ProfileStatus { + CREATED, + CONTACT_INFO_COMPLETED, + PROFILE_COMPLETED, + SYSTEM_APPROVED, + PENDING_ADMIN_APPROVAL, + ADMIN_REJECTED, + ADMIN_APPROVED +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/UpdateProfileRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/UpdateProfileRequest.kt new file mode 100644 index 000000000..4aa769d56 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/UpdateProfileRequest.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class UpdateProfileRequest( + var firstName: String? = null, + var lastName: String? = null, + var address: String? = null, + var telephone: String? = null, + var postalCode: String? = null, + var nationality: String? = null, + var identifier: String? = null, + var gender: Gender? = null, + val mobile: String? = null, + var birthDate: LocalDateTime? = null, +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AccessManagement.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AccessManagement.kt new file mode 100644 index 000000000..f32829e1e --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AccessManagement.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.profile.core.spi + +interface AccessManagement { + fun grantPermission() + fun revokePermission() +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AddressBookPersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AddressBookPersister.kt new file mode 100644 index 000000000..6df4c0a66 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AddressBookPersister.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.profile.AddressBook + +interface AddressBookPersister { + + suspend fun save(addressBook: AddressBook): AddressBook + suspend fun findAll(uuid: String): List + suspend fun update(addressBook: AddressBook): AddressBook + suspend fun delete(uuid: String, id: Long) + suspend fun findSavedAddress(uuid: String, address: String, adressType: String): AddressBook? + +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountPersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountPersister.kt new file mode 100644 index 000000000..4b8aa67dc --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountPersister.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.profile.BankAccount + +interface BankAccountPersister { + suspend fun save(bankAccount: BankAccount) : BankAccount + suspend fun findAll(uuid: String): List + suspend fun findAll(cardNumber: String?, iban: String?): List + suspend fun findAll(uuid: String, cardNumber: String?, iban: String?): List + suspend fun delete(id: Long, uuid: String) +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/InquiryProxy.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/InquiryProxy.kt new file mode 100644 index 000000000..65098b46d --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/InquiryProxy.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.inquiry.ComparativeResponse +import co.nilin.opex.profile.core.data.inquiry.IbanInfoResponse +import co.nilin.opex.profile.core.data.inquiry.ShahkarResponse +import co.nilin.opex.profile.core.data.inquiry.VerifyOwnershipResponse +import java.time.LocalDateTime + +interface InquiryProxy { + suspend fun getShahkarInquiryResult(identifier: String, mobile: String): ShahkarResponse + + suspend fun getComparativeInquiryResult( + nationalCode: String, + birthDate: Long, + firstName: String, + lastName: String + ): ComparativeResponse + + suspend fun verifyCardOwnership( + cardNumber: String, + nationalCode: String, + birthDate: LocalDateTime + ): VerifyOwnershipResponse + + suspend fun verifyIbanOwnership( + iban: String, + nationalCode: String, + birthDate: LocalDateTime + ): VerifyOwnershipResponse + + suspend fun getIbanInfoByCardNumber(cardNumber: String): IbanInfoResponse + suspend fun getIbanInfoByIban(iban: String): IbanInfoResponse + + +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycLevelUpdatedEventListener.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycLevelUpdatedEventListener.kt new file mode 100644 index 000000000..c09548c9c --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycLevelUpdatedEventListener.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent + + +interface KycLevelUpdatedEventListener { + fun id(): String + fun onEvent(event: KycLevelUpdatedEvent, partition: Int, offset: Long, timestamp: Long, eventId: String) + +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycLevelUpdatedPublisher.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycLevelUpdatedPublisher.kt new file mode 100644 index 000000000..e396fca11 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycLevelUpdatedPublisher.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent + + +interface KycLevelUpdatedPublisher { + suspend fun publish(event: KycLevelUpdatedEvent) + +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycProxy.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycProxy.kt new file mode 100644 index 000000000..5536a9c59 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/KycProxy.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.kyc.ManualUpdateRequest + +interface KycProxy { + suspend fun updateKycLevel(updateKycLevelRequest: ManualUpdateRequest) +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/LimitationPersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/LimitationPersister.kt new file mode 100644 index 000000000..0b535f9ba --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/LimitationPersister.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.limitation.* +import kotlinx.coroutines.flow.Flow + +interface LimitationPersister { + suspend fun updateLimitation(updatePermissionRequest: UpdateLimitationRequest) + + suspend fun getLimitation(userId: String?, action: ActionType? = null, reason: LimitationReason? = null, offset: Int? = 0, size: Int? = 1000): Flow? + + suspend fun getLimitationHistory(userId: String?, action: ActionType?, reason: LimitationReason?, offset: Int, size: Int): Flow? + + +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/OtpProxy.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/OtpProxy.kt new file mode 100644 index 000000000..e8f42b9aa --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/OtpProxy.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.otp.NewOTPRequest +import co.nilin.opex.profile.core.data.otp.OTPVerifyResponse +import co.nilin.opex.profile.core.data.otp.TempOtpResponse +import co.nilin.opex.profile.core.data.otp.VerifyOTPRequest + +interface OtpProxy { + suspend fun requestOtp(newOTPRequest: NewOTPRequest) : TempOtpResponse + suspend fun verifyOtp(verifyOTPRequest: VerifyOTPRequest) : OTPVerifyResponse +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfileApprovalRequestPersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfileApprovalRequestPersister.kt new file mode 100644 index 000000000..a4929cf00 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfileApprovalRequestPersister.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.profile.ProfileApprovalAdminResponse +import co.nilin.opex.profile.core.data.profile.ProfileApprovalRequest +import co.nilin.opex.profile.core.data.profile.ProfileApprovalRequestFilter +import co.nilin.opex.profile.core.data.profile.ProfileApprovalUserResponse + +interface ProfileApprovalRequestPersister { + suspend fun save(request: ProfileApprovalRequest): ProfileApprovalRequest + suspend fun getRequests(request: ProfileApprovalRequestFilter): List + suspend fun getRequestById(id: Long): ProfileApprovalAdminResponse + suspend fun getRequestByUserId(userId: String): ProfileApprovalUserResponse + suspend fun update(request: ProfileApprovalAdminResponse): ProfileApprovalAdminResponse +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfilePersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfilePersister.kt new file mode 100644 index 000000000..33e106ed1 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfilePersister.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.kyc.KycLevel +import co.nilin.opex.profile.core.data.profile.* +import reactor.core.publisher.Mono + +interface ProfilePersister { + + suspend fun completeProfile( + id: String, + data: CompleteProfileRequest, + mobileIdentityMatch: Boolean?, + personalIdentityMatch: Boolean? + ): Mono + + suspend fun createProfile(data: Profile): Mono + suspend fun getProfile(userId: String): Profile + suspend fun getAllProfile(profileRequest: ProfileRequest): List + suspend fun resolveUsers(uuids: List): Map + suspend fun getHistory(userId: String, offset: Int, limit: Int): List + suspend fun updateUserLevelAndStatus(userId: String, userLevel: KycLevel, retry: Boolean) + suspend fun validateEmailForUpdate(userId: String, email: String) + suspend fun validateMobileForUpdate(userId: String, mobile: String) + suspend fun updateMobile(userId: String, mobile: String) + suspend fun updateEmail(userId: String, email: String) + suspend fun updateStatus(userId: String, status: ProfileStatus): Profile +} + diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfileUpdatedPublisher.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfileUpdatedPublisher.kt new file mode 100644 index 000000000..b6f10c6e2 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/ProfileUpdatedPublisher.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.event.ProfileUpdatedEvent + + +interface ProfileUpdatedPublisher { + suspend fun publish(event: ProfileUpdatedEvent) +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/UserCreatedEventListener.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/UserCreatedEventListener.kt new file mode 100644 index 000000000..d62f0789b --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/UserCreatedEventListener.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.event.UserCreatedEvent + + +interface UserCreatedEventListener { + fun id(): String + fun onEvent(event: UserCreatedEvent, partition: Int, offset: Long, timestamp: Long, eventId: String) + +} \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Compare.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Compare.kt new file mode 100644 index 000000000..95b9259e2 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Compare.kt @@ -0,0 +1,20 @@ +package co.nilin.opex.profile.core.utils + +import kotlin.reflect.full.memberProperties + +fun Any.compare(s2: Any): List? { + val changedProperties: MutableList = ArrayList() + for (field in this::class.memberProperties) { +// // You might want to set modifier to public first (if it is not public yet) +// field.isAccessible = true + val value1: Any? = field.getter.call(this) + val value2: Any? = field.getter.call(s2) + // if (value1 != null && value2 != null) { + if (value1 != value2) { + if (!field.name.lowercase().contains("date")) + changedProperties.add(field.name) + } + // } + } + return changedProperties +} diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Convertor.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Convertor.kt new file mode 100644 index 000000000..dad9d3fe7 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Convertor.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.profile.core.utils + +import com.google.gson.* +import java.lang.reflect.Type +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +val gson: Gson = GsonBuilder() + .registerTypeAdapter( + LocalDateTime::class.java, + object : JsonSerializer, JsonDeserializer { + private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + + override fun serialize( + src: LocalDateTime?, + typeOfSrc: Type, + context: JsonSerializationContext + ): JsonElement { + return JsonPrimitive(src?.format(formatter)) + } + + override fun deserialize( + json: JsonElement?, + typeOfT: Type, + context: JsonDeserializationContext + ): LocalDateTime { + return LocalDateTime.parse(json!!.asString, formatter) + } + }) + .create() + +fun Any.convert(classOfT: Class): T = gson.fromJson(gson.toJson(this), classOfT) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/InquiryErrorHandler.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/InquiryErrorHandler.kt new file mode 100644 index 000000000..590ea6eea --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/InquiryErrorHandler.kt @@ -0,0 +1,98 @@ +package co.nilin.opex.profile.core.utils + +import co.nilin.opex.common.OpexError + +private fun handleError(code: String?, mapping: Map, default: OpexError) { + if (code.isNullOrEmpty()) return + throw mapping[code]?.exception() ?: default.exception() +} + +object ErrorMappings { + + val shahkar = mapOf( + "invalid.request_body" to OpexError.InvalidRequestParam, + "nationalCode.not_valid" to OpexError.InvalidNationalCode, + "mobileNumber.not_valid" to OpexError.InvalidMobileNumber + ) + + val comparative = mapOf( + "birthDate.not_valid" to OpexError.InvalidBirthDate, + "invalid.request_body" to OpexError.InvalidRequestParam, + "query_parameters.not_provided" to OpexError.InvalidRequestParam, + "identity_info.not_found" to OpexError.IdentityInfoNotFound, + "required_is.nationalCode" to OpexError.InvalidNationalCode, + "nationalCode.not_valid" to OpexError.InvalidNationalCode, + "birthDate.is_required" to OpexError.InvalidBirthDate + ) + + val cardOwnership = mapOf( + "invalid.request_body" to OpexError.InvalidRequestBody, + "forbidden" to OpexError.Forbidden, + "card.not_valid" to OpexError.InvalidCard, + "card.not_active" to OpexError.CardNotActive, + "card.is_expired" to OpexError.CardIsExpired, + "card.account_number_not_valid" to OpexError.CardAccountNumberNotValid, + "card.registered_as_lost" to OpexError.CardRegisteredAsLost, + "card.registered_as_stolen" to OpexError.CardRegisteredAsStolen, + "card.source_bank_is_not_active" to OpexError.CardSourceBankNotActive, + "card.black_listed" to OpexError.CardBlackListed, + "identity_info.not_found" to OpexError.IdentityInfoNotFound + ) + + val ibanOwnership = mapOf( + "invalid.request_body" to OpexError.InvalidRequestBody, + "forbidden" to OpexError.Forbidden, + "iban.is_required" to OpexError.InvalidRequestBody, + "iban.not_valid" to OpexError.InvalidIban, + "identity_info.not_found" to OpexError.IdentityInfoNotFound + ) + + val ibanInfo = mapOf( + "invalid.request_body" to OpexError.InvalidRequestBody, + "forbidden" to OpexError.Forbidden, + "iban.is_required" to OpexError.IbanIsRequired, + "iban.not_valid" to OpexError.InvalidIban, + "iban.not_found" to OpexError.IbanNotFound, + "iban.owner_not_found" to OpexError.IbanOwnerNotFound + ) + + val cardIbanInfo = mapOf( + "invalid.request_body" to OpexError.InvalidRequestBody, + "forbidden" to OpexError.Forbidden, + "card.required" to OpexError.CardRequired, + "card.is_required" to OpexError.CardRequired, + "card.not_valid" to OpexError.InvalidCard, + "card.type.not_supported" to OpexError.CardTypeNotSupported, + "parameters.not_acceptable" to OpexError.InvalidRequestParam, + "card.not_active" to OpexError.CardNotActive, + "card.is_expired" to OpexError.CardIsExpired, + "card.account_number_not_valid" to OpexError.CardAccountNumberNotValid, + "card_number.not_valid" to OpexError.InvalidCard, + "iban.not_found" to OpexError.IbanNotFound, + "iban.not_valid" to OpexError.InvalidIban, + "iban.owner.not_found" to OpexError.IbanOwnerNotFound, + "card.black_listed" to OpexError.CardBlackListed, + "card.registered_as_lost" to OpexError.CardRegisteredAsLost, + "card.registered_as_stolen" to OpexError.CardRegisteredAsStolen, + "card.source_bank_is_not_active" to OpexError.CardSourceBankNotActive, + "iban.black_listed" to OpexError.IbanBlackListed + ) +} + +fun handleShahkarError(code: String?) = + handleError(code, ErrorMappings.shahkar, OpexError.ShahkarInquiryError) + +fun handleComparativeError(code: String?) = + handleError(code, ErrorMappings.comparative, OpexError.ComparativeInquiryError) + +fun handleCardOwnershipError(code: String?) = + handleError(code, ErrorMappings.cardOwnership, OpexError.CardOwnershipInquiryError) + +fun handleIbanOwnershipError(code: String?) = + handleError(code, ErrorMappings.ibanOwnership, OpexError.IbanOwnershipInquiryError) + +fun handleIbanInfoError(code: String?) = + handleError(code, ErrorMappings.ibanInfo, OpexError.IbanInfoInquiryError) + +fun handleCardIbanInfoError(code: String?) = + handleError(code, ErrorMappings.cardIbanInfo, OpexError.CardIbanInfoInquiryError) \ No newline at end of file diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Validation.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Validation.kt new file mode 100644 index 000000000..3ccf3818a --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/Validation.kt @@ -0,0 +1,61 @@ +package co.nilin.opex.profile.core.utils + + +import java.math.BigInteger +import java.util.Locale + + +private val IBAN_VALIDATION_DIVISOR = BigInteger("97") +private val IBAN_VALIDATION_REMAINDER = BigInteger("1") + +/** + * Check Bank card number validation + * + * @return return true if it is valid card number otherwise false + */ +fun CharSequence?.isValidCardNumber(): Boolean { + this ?: return false + if (!Regex("\\d{16}").matches(this)) + return false + var sum = 0 + for (i in indices) sum += try { + val character = get(i).toString().toInt() + if (i % 2 == 0) { + val temp = character * 2 + if (temp > 9) temp - 9 else temp + } else character + } catch (e: NumberFormatException) { + return false + } + return sum % 10 == 0 +} + +/** + * Check Bank IBAN number validation + * + * @return return true if it is valid IBAN number otherwise false + */ +fun CharSequence?.isValidIBAN(): Boolean { + this ?: return false + + val iban = if (!toString().toUpperCase(Locale.ENGLISH).startsWith("IR")) { + "IR$this" + } else { + this + } + if (iban.length != 26) return false + if (!iban.startsWith("IR")) return false + val cc = iban.substring(0, 2) + val cd = iban.substring(2, 4) + val bban = iban.substring(4, 26) + val ccFirstCharValue = getCountryCodeValue(cc[0]) + val ccSecondCharValue = getCountryCodeValue(cc[1]) + val newIBAN = BigInteger(bban + ccFirstCharValue + ccSecondCharValue + cd) + return newIBAN.mod(IBAN_VALIDATION_DIVISOR).compareTo(IBAN_VALIDATION_REMAINDER) == 0 +} + +private fun getCountryCodeValue(c: Char): Int { + return c - 'A' + 10 +} + + diff --git a/profile/profile-core/src/main/resources/application.properties b/profile/profile-core/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/profile/profile-core/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/profile/profile-core/src/test/kotlin/co/nilin/opex/profile/core/ProfileCoreApplicationTests.kt b/profile/profile-core/src/test/kotlin/co/nilin/opex/profile/core/ProfileCoreApplicationTests.kt new file mode 100644 index 000000000..25cf38ccf --- /dev/null +++ b/profile/profile-core/src/test/kotlin/co/nilin/opex/profile/core/ProfileCoreApplicationTests.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.profile.core + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +//@SpringBootTest +//class ProfileCoreApplicationTests { +// +// @Test +// fun contextLoads() { +// } +// +//} diff --git a/profile/profile-ports/profile-eventlistener-kafka/.gitignore b/profile/profile-ports/profile-eventlistener-kafka/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-ports/profile-eventlistener-kafka/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-ports/profile-eventlistener-kafka/pom.xml b/profile/profile-ports/profile-eventlistener-kafka/pom.xml new file mode 100644 index 000000000..a6077d0ef --- /dev/null +++ b/profile/profile-ports/profile-eventlistener-kafka/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + ../../pom.xml + + co.nilin.opex.profile.ports + profile-eventlistener-kafka + profile-eventlistener-kafka + profile-kafka + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.springframework.kafka + spring-kafka + + + org.springframework.boot + spring-boot-starter-test + test + + + co.nilin.opex.profile + profile-core + + + + + diff --git a/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaListenerConfig.kt b/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaListenerConfig.kt new file mode 100644 index 000000000..31919935a --- /dev/null +++ b/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaListenerConfig.kt @@ -0,0 +1,109 @@ +package co.nilin.opex.profile.ports.kafka.config + + +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent +import co.nilin.opex.profile.core.data.event.UserCreatedEvent +import co.nilin.opex.profile.ports.kafka.consumer.KycLevelUpdatedKafkaListener +import co.nilin.opex.profile.ports.kafka.consumer.UserCreatedKafkaListener +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.serialization.StringDeserializer +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.listener.* +import org.springframework.kafka.support.serializer.JsonDeserializer +import org.springframework.util.backoff.FixedBackOff +import java.util.regex.Pattern + +@Configuration +class KafkaListenerConfig { + private val logger = LoggerFactory.getLogger(KafkaListenerConfig::class.java) + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean("consumerConfigs") + fun consumerConfigs(): Map { + + return mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, + JsonDeserializer.TRUSTED_PACKAGES to "co.nilin.opex.*", + JsonDeserializer.TYPE_MAPPINGS to "userCreatedEvent:co.nilin.opex.profile.core.data.event.UserCreatedEvent,kyc_level_updated_event:co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent" + ) + } + + @Bean("profileConsumerFactory") + fun consumerFactory(@Qualifier("consumerConfigs") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + @Bean("profileProducerFactory") + fun producerFactory(@Qualifier("consumerConfigs") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean("profileKafkaTemplate") + fun kafkaTemplate(@Qualifier("profileProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + + @Bean("kycConsumerFactory") + fun kycConsumerFactory(@Qualifier("consumerConfigs") consumerConfigs: Map): ConsumerFactory { + return DefaultKafkaConsumerFactory(consumerConfigs) + } + + @Autowired + @ConditionalOnBean(UserCreatedKafkaListener::class) + fun configureUserCreatedListener( + listener: UserCreatedKafkaListener, + @Qualifier("profileKafkaTemplate") template: KafkaTemplate, + @Qualifier("profileConsumerFactory") consumerFactory: ConsumerFactory + ) { + val containerProps = ContainerProperties(Pattern.compile("auth")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("UserCreatedKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "auth.DLT") + container.start() + } + + + @Autowired + @ConditionalOnBean(KycLevelUpdatedKafkaListener::class) + fun configureKycLevelUpdatedListener( + listener: KycLevelUpdatedKafkaListener, + template: KafkaTemplate, + @Qualifier("kycConsumerFactory") consumerFactory: ConsumerFactory + ) { + val containerProps = ContainerProperties(Pattern.compile("kyc_level_updated")) + containerProps.messageListener = listener + val container = ConcurrentMessageListenerContainer(consumerFactory, containerProps) + container.setBeanName("KycLevelUpdatedKafkaListenerContainer") + container.commonErrorHandler = createConsumerErrorHandler(template, "kyc_level_updated.DLT") + container.start() + } + + private fun createConsumerErrorHandler(kafkaTemplate: KafkaTemplate<*, *>, dltTopic: String): CommonErrorHandler { + val recoverer = DeadLetterPublishingRecoverer(kafkaTemplate) { cr, _ -> + cr.headers().add("dlt-origin-module", "PROFILE".toByteArray()) + TopicPartition(dltTopic, cr.partition()) + } + return DefaultErrorHandler(recoverer, FixedBackOff(5_000, 20)) + } + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/consumer/KycLevelUpdatedKafkaListener.kt b/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/consumer/KycLevelUpdatedKafkaListener.kt new file mode 100644 index 000000000..12f0dc59a --- /dev/null +++ b/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/consumer/KycLevelUpdatedKafkaListener.kt @@ -0,0 +1,34 @@ +package co.nilin.opex.profile.ports.kafka.consumer + +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent +import co.nilin.opex.profile.core.spi.KycLevelUpdatedEventListener +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.listener.MessageListener +import org.springframework.stereotype.Component + +@Component +class KycLevelUpdatedKafkaListener : MessageListener { + val eventListeners = arrayListOf() + private val logger = LoggerFactory.getLogger(KycLevelUpdatedKafkaListener::class.java) + override fun onMessage(data: ConsumerRecord) { + + eventListeners.forEach { tl -> + logger.info("incoming new event " + tl.id()) + tl.onEvent(data.value(), data.partition(), data.offset(), data.timestamp(), tl.id()) + } + } + + fun addEventListener(tl: KycLevelUpdatedEventListener) { + eventListeners.add(tl) + } + + fun removeEventListener(tl: KycLevelUpdatedEventListener) { + eventListeners.removeIf { item -> + item.id() == tl.id() + } + + } + + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/consumer/UserCreatedKafkaListener.kt b/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/consumer/UserCreatedKafkaListener.kt new file mode 100644 index 000000000..4aab935e1 --- /dev/null +++ b/profile/profile-ports/profile-eventlistener-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/consumer/UserCreatedKafkaListener.kt @@ -0,0 +1,32 @@ +package co.nilin.opex.profile.ports.kafka.consumer + + +import co.nilin.opex.profile.core.spi.UserCreatedEventListener +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.listener.MessageListener +import org.springframework.stereotype.Component +import co.nilin.opex.profile.core.data.event.UserCreatedEvent + +@Component +class UserCreatedKafkaListener : MessageListener { + val eventListeners = arrayListOf() + private val logger = LoggerFactory.getLogger(UserCreatedKafkaListener::class.java) + override fun onMessage(data: ConsumerRecord) { + + eventListeners.forEach { tl -> + logger.info("incoming new event " + tl.id()) + tl.onEvent(data.value(), data.partition(), data.offset(), data.timestamp(), tl.id()) + } + } + + fun addEventListener(tl: UserCreatedEventListener) { + eventListeners.add(tl) + } + + fun removeEventListener(tl: UserCreatedEventListener) { + eventListeners.removeIf { item -> + item.id() == tl.id() + } + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-eventlistener-kafka/src/main/resources/application.properties b/profile/profile-ports/profile-eventlistener-kafka/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/profile/profile-ports/profile-eventlistener-kafka/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/profile/profile-ports/profile-inquiry-proxy/.gitignore b/profile/profile-ports/profile-inquiry-proxy/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-ports/profile-inquiry-proxy/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-ports/profile-inquiry-proxy/pom.xml b/profile/profile-ports/profile-inquiry-proxy/pom.xml new file mode 100644 index 000000000..f4c211b5e --- /dev/null +++ b/profile/profile-ports/profile-inquiry-proxy/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + ../../pom.xml + + co.nilin.opex.profile.ports + profile-inquiry-proxy + profile-shahkar-proxy + profile-shahkar-proxy + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.springframework.boot + spring-boot-starter-test + test + + + + co.nilin.opex.profile + profile-core + + + com.ibm.icu + icu4j + 74.1 + + + + + diff --git a/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/data/TokenResponse.kt b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/data/TokenResponse.kt new file mode 100644 index 000000000..6b4604d3e --- /dev/null +++ b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/data/TokenResponse.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.ports.inquiry.data + +data class TokenResponse( + val accessToken: String +) diff --git a/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/imp/InquiryProxyImp.kt b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/imp/InquiryProxyImp.kt new file mode 100644 index 000000000..59e9bef1d --- /dev/null +++ b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/imp/InquiryProxyImp.kt @@ -0,0 +1,108 @@ +package co.nilin.opex.profile.ports.inquiry.imp + +import co.nilin.opex.profile.core.data.inquiry.ComparativeResponse +import co.nilin.opex.profile.core.data.inquiry.IbanInfoResponse +import co.nilin.opex.profile.core.data.inquiry.ShahkarResponse +import co.nilin.opex.profile.core.data.inquiry.VerifyOwnershipResponse +import co.nilin.opex.profile.core.spi.InquiryProxy +import co.nilin.opex.profile.ports.inquiry.utils.TokenProvider +import co.nilin.opex.profile.ports.inquiry.utils.toPersianDateFormatted +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import java.time.LocalDateTime + +@Component +class InquiryProxyImp( + @Qualifier("plainWebClient") private val webClient: WebClient, private val tokenProvider: TokenProvider +) : InquiryProxy { + + @Value("\${app.inquiry.url}") + private lateinit var baseUrl: String + + override suspend fun getShahkarInquiryResult(identifier: String, mobile: String): ShahkarResponse { + return webClient.get().uri("$baseUrl/v1/services/matching") { + it.queryParam("nationalCode", identifier) + it.queryParam("mobileNumber", mobile) + it.build() + }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) + .exchangeToMono { clientResponse -> + clientResponse.bodyToMono(ShahkarResponse::class.java) + }.awaitSingle() + } + + override suspend fun getComparativeInquiryResult( + nationalCode: String, birthDate: Long, firstName: String, lastName: String + ): ComparativeResponse { + val birthDateFormatted = birthDate.toPersianDateFormatted() + + return webClient.get().uri("$baseUrl/v1/services/identity/similarity") { + it.queryParam("nationalCode", nationalCode) + it.queryParam("birthDate", birthDateFormatted) + it.queryParam("firstName", firstName) + it.queryParam("lastName", lastName) + it.build() + }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) + .exchangeToMono { clientResponse -> + clientResponse.bodyToMono(ComparativeResponse::class.java) + }.awaitSingle() + } + + override suspend fun verifyCardOwnership( + cardNumber: String, nationalCode: String, birthDate: LocalDateTime + ): VerifyOwnershipResponse { + val birthDateFormatted = birthDate.toPersianDateFormatted() + + return webClient.get().uri("$baseUrl/v1/services/matching") { + it.queryParam("cardNumber", cardNumber) + it.queryParam("nationalCode", nationalCode) + it.queryParam("birthDate", birthDateFormatted) + it.build() + }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) + .exchangeToMono { clientResponse -> + clientResponse.bodyToMono(VerifyOwnershipResponse::class.java) + }.awaitSingle() + } + + + override suspend fun verifyIbanOwnership( + iban: String, nationalCode: String, birthDate: LocalDateTime + ): VerifyOwnershipResponse { + val birthDateFormatted = birthDate.toPersianDateFormatted() + + return webClient.get().uri("$baseUrl/v1/services/matching") { + it.queryParam("iban", iban) + it.queryParam("nationalCode", nationalCode) + it.queryParam("birthDate", birthDateFormatted) + it.build() + }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) + .exchangeToMono { clientResponse -> + clientResponse.bodyToMono(VerifyOwnershipResponse::class.java) + }.awaitSingle() + } + + override suspend fun getIbanInfoByCardNumber(cardNumber: String): IbanInfoResponse { + return webClient.get().uri("$baseUrl/v1/cards") { + it.queryParam("number", cardNumber) + it.queryParam("iban", true) + it.build() + }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) + .exchangeToMono { clientResponse -> + clientResponse.bodyToMono(IbanInfoResponse::class.java) + }.awaitSingle() + } + + override suspend fun getIbanInfoByIban(iban: String): IbanInfoResponse { + return webClient.get().uri("$baseUrl/v1/ibans") { + it.queryParam("value", iban) + it.build() + }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) + .exchangeToMono { clientResponse -> + clientResponse.bodyToMono(IbanInfoResponse::class.java) + }.awaitSingle() + } +} diff --git a/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/utils/DateConvertor.kt b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/utils/DateConvertor.kt new file mode 100644 index 000000000..801dcbfdb --- /dev/null +++ b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/utils/DateConvertor.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.profile.ports.inquiry.utils + +import com.ibm.icu.util.Calendar +import com.ibm.icu.util.TimeZone +import com.ibm.icu.util.ULocale +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +private val persianLocale = ULocale("fa_IR@calendar=persian") +private val tehranTimeZone = TimeZone.getTimeZone("Asia/Tehran") + +private fun formatPersianDate(date: Date): String { + val calendar = Calendar.getInstance(persianLocale).apply { + timeZone = tehranTimeZone + time = date + } + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + 1 + val day = calendar.get(Calendar.DAY_OF_MONTH) + return String.format("%04d%02d%02d", year, month, day) +} + +fun Long.toPersianDateFormatted(): String = + formatPersianDate(Date(this)) + +fun LocalDateTime.toPersianDateFormatted(): String = + formatPersianDate(Date.from(this.atZone(ZoneId.of("Asia/Tehran")).toInstant())) \ No newline at end of file diff --git a/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/utils/TokenProvider.kt b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/utils/TokenProvider.kt new file mode 100644 index 000000000..b77545dad --- /dev/null +++ b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/utils/TokenProvider.kt @@ -0,0 +1,55 @@ +package co.nilin.opex.profile.ports.inquiry.utils + +import co.nilin.opex.profile.ports.inquiry.data.TokenResponse +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import java.time.Duration +import java.time.Instant + +@Service +class TokenProvider( + @Qualifier("plainWebClient") private val webClient: WebClient, + @Value("\${app.inquiry.url}") private var baseUrl: String, + @Value("\${app.inquiry.api-key}") private var apiKey: String, + @Value("\${app.inquiry.secret-key}") private var secretKey: String, +) { + + @Volatile + private var cachedToken: String? = null + + @Volatile + private var expiryTime: Instant? = null + + private val tokenLock = Mutex() + + suspend fun getToken(): String { + + val now = Instant.now() + if (cachedToken != null && expiryTime != null && now.isBefore(expiryTime)) { + return cachedToken!! + } + tokenLock.withLock { + if (cachedToken == null || expiryTime == null || now.isAfter(expiryTime)) { + val response = + webClient.post() + .uri("$baseUrl/v1/tokens/generate") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(mapOf("apiKey" to apiKey, "secretKey" to secretKey)) + .retrieve() + .bodyToMono(TokenResponse::class.java) + .awaitSingle() + + cachedToken = response.accessToken + expiryTime = now.plus(Duration.ofHours(23)) + } + + return cachedToken!! + } + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-inquiry-proxy/src/main/resources/application.properties b/profile/profile-ports/profile-inquiry-proxy/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/profile/profile-ports/profile-inquiry-proxy/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/profile/profile-ports/profile-kyc-proxy/.gitignore b/profile/profile-ports/profile-kyc-proxy/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-ports/profile-kyc-proxy/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-ports/profile-kyc-proxy/pom.xml b/profile/profile-ports/profile-kyc-proxy/pom.xml new file mode 100644 index 000000000..caefb1761 --- /dev/null +++ b/profile/profile-ports/profile-kyc-proxy/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + ../../pom.xml + + co.nilin.opex.profile.ports + profile-kyc-proxy + profile-kyc-proxy + profile-kyc-proxy + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.springframework.boot + spring-boot-starter-test + test + + + + co.nilin.opex.profile + profile-core + + + + + diff --git a/profile/profile-ports/profile-kyc-proxy/src/main/kotlin/co/nilin/opex/profile/ports/kyc/imp/KycProxyImp.kt b/profile/profile-ports/profile-kyc-proxy/src/main/kotlin/co/nilin/opex/profile/ports/kyc/imp/KycProxyImp.kt new file mode 100644 index 000000000..983ef438e --- /dev/null +++ b/profile/profile-ports/profile-kyc-proxy/src/main/kotlin/co/nilin/opex/profile/ports/kyc/imp/KycProxyImp.kt @@ -0,0 +1,37 @@ +package co.nilin.opex.profile.ports.kyc.imp + +import co.nilin.opex.profile.core.data.kyc.ManualUpdateRequest +import co.nilin.opex.profile.core.spi.KycProxy +import kotlinx.coroutines.reactive.awaitFirst +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import java.net.URI + +inline fun typeRef(): ParameterizedTypeReference = object : ParameterizedTypeReference() {} + +@Component +class KycProxyImp(@Qualifier("loadBalanced") private val webClient: WebClient) : KycProxy { + @Value("\${app.kyc.url}") + private lateinit var baseUrl: String + private val logger = LoggerFactory.getLogger(KycProxyImp::class.java) + + + override suspend fun updateKycLevel(updateKycLevelRequest: ManualUpdateRequest) { + webClient.put() + .uri(URI.create("$baseUrl/${updateKycLevelRequest.userId}")) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(updateKycLevelRequest)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirst() + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-kyc-proxy/src/main/resources/application.properties b/profile/profile-ports/profile-kyc-proxy/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/profile/profile-ports/profile-kyc-proxy/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/profile/profile-ports/profile-kyc-proxy/src/test/kotlin/co/nilin/opex/profile/ports/kafka/ProfilePostgressApplicationTests.kt b/profile/profile-ports/profile-kyc-proxy/src/test/kotlin/co/nilin/opex/profile/ports/kafka/ProfilePostgressApplicationTests.kt new file mode 100644 index 000000000..577be1be0 --- /dev/null +++ b/profile/profile-ports/profile-kyc-proxy/src/test/kotlin/co/nilin/opex/profile/ports/kafka/ProfilePostgressApplicationTests.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.kafka + +//@SpringBootTest +//class ProfilePostgressApplicationTests { +// +// @Test +// fun contextLoads() { +// } +// +//} diff --git a/profile/profile-ports/profile-otp-proxy/.gitignore b/profile/profile-ports/profile-otp-proxy/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-ports/profile-otp-proxy/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-ports/profile-otp-proxy/pom.xml b/profile/profile-ports/profile-otp-proxy/pom.xml new file mode 100644 index 000000000..4b0c744b6 --- /dev/null +++ b/profile/profile-ports/profile-otp-proxy/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + ../../pom.xml + + co.nilin.opex.profile.ports + profile-otp-proxy + profile-otp-proxy + profile-otp-proxy + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.springframework.boot + spring-boot-starter-test + test + + + + co.nilin.opex.profile + profile-core + + + + + diff --git a/profile/profile-ports/profile-otp-proxy/src/main/kotlin/co/nilin/opex/profile/ports/otp/impl/OtpProxyImpl.kt b/profile/profile-ports/profile-otp-proxy/src/main/kotlin/co/nilin/opex/profile/ports/otp/impl/OtpProxyImpl.kt new file mode 100644 index 000000000..c8fa2a788 --- /dev/null +++ b/profile/profile-ports/profile-otp-proxy/src/main/kotlin/co/nilin/opex/profile/ports/otp/impl/OtpProxyImpl.kt @@ -0,0 +1,45 @@ +package co.nilin.opex.profile.ports.otp.impl + +import co.nilin.opex.profile.core.data.otp.NewOTPRequest +import co.nilin.opex.profile.core.data.otp.OTPVerifyResponse +import co.nilin.opex.profile.core.data.otp.TempOtpResponse +import co.nilin.opex.profile.core.data.otp.VerifyOTPRequest +import co.nilin.opex.profile.core.spi.OtpProxy +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody + +@Component +class OtpProxyImpl(private val webClient: WebClient) : OtpProxy { + + @Value("\${app.otp.url}") + private lateinit var baseUrl: String + + override suspend fun requestOtp(newOTPRequest: NewOTPRequest): TempOtpResponse { + return webClient.post().uri("$baseUrl/otp") + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(newOTPRequest)) + .retrieve() + .awaitBody() + } + + override suspend fun verifyOtp(verifyOTPRequest: VerifyOTPRequest): OTPVerifyResponse { + val request = object { + val userId = verifyOTPRequest.userId + val otpCodes = verifyOTPRequest.otpCodes.map { + object { + val type = it.type + val code = it.code + } + } + } + return webClient.post().uri("$baseUrl/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(request)) + .retrieve() + .awaitBody() + } +} diff --git a/profile/profile-ports/profile-otp-proxy/src/main/resources/application.properties b/profile/profile-ports/profile-otp-proxy/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/profile/profile-ports/profile-otp-proxy/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/profile/profile-ports/profile-postgres/.gitignore b/profile/profile-ports/profile-postgres/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-ports/profile-postgres/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-ports/profile-postgres/pom.xml b/profile/profile-ports/profile-postgres/pom.xml new file mode 100644 index 000000000..98c9d8f09 --- /dev/null +++ b/profile/profile-ports/profile-postgres/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + ../../pom.xml + + co.nilin.opex.profile.ports + profile-postgres + profile-postgres + profile-postgres + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + org.postgresql + r2dbc-postgresql + runtime + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + co.nilin.opex.profile + profile-core + + + co.nilin.opex.profile.ports + profile-kyc-proxy + + + co.nilin.opex.profile.ports + profile-inquiry-proxy + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.flywaydb + flyway-core + + + + + diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/ProfileFullNameProjection.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/ProfileFullNameProjection.kt new file mode 100644 index 000000000..234944031 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/ProfileFullNameProjection.kt @@ -0,0 +1,4 @@ +package co.nilin.opex.profile.ports.postgres + +data class ProfileFullNameProjection( val uuid: String, + val fullName: String?) diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/config/PostgresConfig.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/config/PostgresConfig.kt new file mode 100644 index 000000000..1e648782f --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/config/PostgresConfig.kt @@ -0,0 +1,52 @@ +package co.nilin.opex.profile.ports.postgres.config + +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories + +@Configuration +@EnableR2dbcRepositories(basePackages = ["co.nilin.opex"]) +class PostgresConfig { + private val logger = LoggerFactory.getLogger(PostgresConfig::class.java) + + init { + logger.info("🔍 PostgresConfig loaded") + } + @Bean + fun flywayConfig(@Value("\${spring.datasource.url}") url: String, + @Value("\${spring.datasource.username}") user: String, + @Value("\${spring.datasource.password}") password: String): Flyway? { + + val flyway: Flyway = Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .baselineVersion("1") + .load() + try { + retry(6, 5000) { + flyway.migrate() + } + } catch (e: Exception) { + logger.error("❌ Flyway migration failed", e) + } + return flyway + } + + fun retry(times: Int, delayMs: Long, block: () -> Unit) { + var attempt = 0 + while (true) { + try { + block() + return + } catch (e: Exception) { + if (++attempt >= times) throw e + Thread.sleep(delayMs) + } + } + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/AddressBookRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/AddressBookRepository.kt new file mode 100644 index 000000000..93d4ddaeb --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/AddressBookRepository.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.core.data.profile.AddressBook +import co.nilin.opex.profile.ports.postgres.model.entity.AddressBookModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Repository +interface AddressBookRepository : ReactiveCrudRepository { + + @Query("select * from address_book where uuid = :uuid") + suspend fun findAllByUuid(uuid: String): Flux + suspend fun findByUuidAndAddressAndAddressType(uuid: String, address: String, type: String): Mono? + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/BankAccountRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/BankAccountRepository.kt new file mode 100644 index 000000000..e83890905 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/BankAccountRepository.kt @@ -0,0 +1,48 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.core.data.profile.BankAccount +import co.nilin.opex.profile.ports.postgres.model.entity.BankAccountModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface BankAccountRepository : ReactiveCrudRepository { + + @Query("select * from bank_account where uuid = :uuid") + suspend fun findAllByUuid(uuid: String): Flux + + @Query( + """ + SELECT * + FROM bank_account + WHERE + (:cardNumber IS NOT NULL AND card_number = :cardNumber) + OR + (:iban IS NOT NULL AND iban = :iban) +""" + ) + suspend fun findByCardNumberOrIban( + cardNumber: String?, + iban: String? + ): Flux + + @Query( + """ + SELECT * + FROM bank_account + WHERE + ((:cardNumber IS NOT NULL AND card_number = :cardNumber) + OR + (:iban IS NOT NULL AND iban = :iban)) + AND uuid = :uuid +""" + ) + suspend fun findByUuidAndCardNumberOrIban( + uuid: String, + cardNumber: String?, + iban: String? + ): Flux + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/LimitationHistoryRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/LimitationHistoryRepository.kt new file mode 100644 index 000000000..ece2e5595 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/LimitationHistoryRepository.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.core.data.limitation.ActionType +import co.nilin.opex.profile.core.data.limitation.LimitationReason +import co.nilin.opex.profile.ports.postgres.model.history.LimitationHistory +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Pageable +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface LimitationHistoryRepository : ReactiveCrudRepository { + @Query("select * from limitation_history l where (:userId is NULL or l.user_id= :userId) And (:action is NULL or l.action_type=:action) And (:reason is NULL or l.reason=:reason) OFFSET :offset LIMIT :size; ") + fun findAllLimitationHistory(userId: String?, action: ActionType?, reason: LimitationReason?, offset: Int, size: Int, pageable: Pageable): Flow? + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/LimitationRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/LimitationRepository.kt new file mode 100644 index 000000000..66c182b5c --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/LimitationRepository.kt @@ -0,0 +1,25 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.core.data.limitation.ActionType +import co.nilin.opex.profile.core.data.limitation.LimitationReason +import co.nilin.opex.profile.ports.postgres.model.entity.LimitationModel +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Pageable +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono + +@Repository +interface LimitationRepository : ReactiveCrudRepository { + fun findByLimitationOn(data: String): Mono? + fun deleteByLimitationOn(data: String): Mono + fun deleteByUserId(userId: String): Mono + + fun deleteByActionType(actionType: ActionType): Mono + + @Query("select * from limitation l where (:userId is NULL or l.user_id= :userId) And (:action is NULL or l.action_type=:action) And (:reason is NULL or l.reason=:reason) OFFSET :offset LIMIT :size; ") + fun findAllLimitation(userId: String?, action: ActionType?, reason: LimitationReason?, offset: Int, size: Int, pageable: Pageable): Flow? + + +} \ No newline at end of file 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 new file mode 100644 index 000000000..03478a113 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileApprovalRequestRepository.kt @@ -0,0 +1,84 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.core.data.profile.ProfileApprovalAdminResponse +import co.nilin.opex.profile.core.data.profile.ProfileApprovalRequestStatus +import co.nilin.opex.profile.core.data.profile.ProfileApprovalUserResponse +import co.nilin.opex.profile.ports.postgres.model.entity.ProfileApprovalRequestModel +import kotlinx.coroutines.flow.Flow +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import reactor.core.publisher.Mono +import java.time.LocalDateTime + +interface ProfileApprovalRequestRepository : ReactiveCrudRepository { + + fun findByUserIdAndStatus( + userId: String, + status: ProfileApprovalRequestStatus + ): Mono + + @Query( + """ + SELECT + p.id, + p.user_id, + p.status, + p.create_date, + p.update_date, + p.updater, + p.description, + u.first_name, + u.last_name + FROM profile_approval_request p + LEFT JOIN profile u ON u.user_id = p.user_id + WHERE (:userId IS NULL OR p.user_id = :userId) + AND (:status IS NULL OR p.status = :status) + AND (:createDateFrom IS NULL OR p.create_date >= :createDateFrom) + AND (:createDateTo IS NULL OR p.create_date <= :createDateTo) + ORDER BY create_date + LIMIT :limit OFFSET :offset +""" + ) + fun findByCriteriaAsc( + userId: String?, + status: ProfileApprovalRequestStatus?, + createDateFrom: LocalDateTime?, + createDateTo: LocalDateTime?, + limit: Int, + offset: Int, + ): Flow + + @Query( + """ + SELECT + p.id, + p.user_id, + p.status, + p.create_date, + p.update_date, + p.updater, + p.description, + u.first_name, + u.last_name + FROM profile_approval_request p + LEFT JOIN profile u ON u.user_id = p.user_id + WHERE (:userId IS NULL OR p.user_id = :userId) + AND (:status IS NULL OR p.status = :status) + AND (:createDateFrom IS NULL OR p.create_date >= :createDateFrom) + AND (:createDateTo IS NULL OR p.create_date <= :createDateTo) + ORDER BY create_date desc + LIMIT :limit OFFSET :offset +""" + ) + fun findByCriteriaDesc( + userId: String?, + status: ProfileApprovalRequestStatus?, + createDateFrom: LocalDateTime?, + createDateTo: LocalDateTime?, + limit: Int, + offset: Int, + ): 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 +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileHistoryRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileHistoryRepository.kt new file mode 100644 index 000000000..437478a17 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileHistoryRepository.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.ports.postgres.model.history.ProfileHistory +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ProfileHistoryRepository : ReactiveCrudRepository { + fun findByUserId(userId: String, pageable: Pageable): Flow +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileRepository.kt new file mode 100644 index 000000000..cefd8aa8a --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/ProfileRepository.kt @@ -0,0 +1,118 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.core.data.kyc.KycLevel +import co.nilin.opex.profile.core.data.profile.Gender +import co.nilin.opex.profile.core.data.profile.NationalityType +import co.nilin.opex.profile.core.data.profile.ProfileStatus +import co.nilin.opex.profile.ports.postgres.ProfileFullNameProjection +import co.nilin.opex.profile.ports.postgres.model.entity.ProfileModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.LocalDateTime + +@Repository +interface ProfileRepository : ReactiveCrudRepository { + + fun findByUserId(userId: String): Mono + + @Query("select * from profile where identifier = :identifier order by last_update_date desc limit 1") + fun findLatestByIdentifier(identifier: String ): Mono + + @Query("select p.user_id AS uuid, CONCAT (p.first_name, ' ', p.last_name) AS full_name from profile p where p.user_id in (:uuids)") + fun findFullNameByUuid(uuids: List): Flux + + @Query( + """ + SELECT * + FROM profile p + WHERE (:userId IS NULL OR p.user_id = :userId) + AND (:firstName IS NULL OR p.first_name ILIKE CONCAT('%', :firstName, '%')) + AND (:lastName IS NULL OR p.last_name ILIKE CONCAT('%', :lastName, '%')) + AND (:mobile IS NULL OR p.mobile ILIKE CONCAT('%', :mobile, '%')) + AND (:email IS NULL OR p.email ILIKE CONCAT('%', :email, '%')) + AND (:identifier IS NULL OR p.identifier = :identifier) + AND (:nationality IS NULL OR p.nationality = :nationality) + AND (:gender IS NULL OR p.gender = :gender) + AND (:status IS NULL OR p.status = :status) + AND (:kycLevel IS NULL OR p.kyc_level = :kycLevel) + AND (:createDateFrom IS NULL OR p.create_date >= :createDateFrom) + AND (:createDateTo IS NULL OR p.create_date <= :createDateTo) + ORDER BY p.create_date DESC + LIMIT :limit OFFSET :offset + """ + ) + fun findByCriteriaDesc( + userId: String?, + firstName: String?, + lastName: String?, + mobile: String?, + email: String?, + identifier: String?, + nationality: NationalityType?, + gender: Gender?, + status: ProfileStatus?, + kycLevel: KycLevel?, + createDateFrom: LocalDateTime?, + createDateTo: LocalDateTime?, + limit: Int, + offset: Int + ): Flux + + @Query( + """ + SELECT * + FROM profile p + WHERE (:userId IS NULL OR p.user_id = :userId) + AND (:firstName IS NULL OR p.first_name ILIKE CONCAT('%', :firstName, '%')) + AND (:lastName IS NULL OR p.last_name ILIKE CONCAT('%', :lastName, '%')) + AND (:mobile IS NULL OR p.mobile ILIKE CONCAT('%', :mobile, '%')) + AND (:email IS NULL OR p.email ILIKE CONCAT('%', :email, '%')) + AND (:identifier IS NULL OR p.identifier = :identifier) + AND (:nationality IS NULL OR p.nationality = :nationality) + AND (:gender IS NULL OR p.gender = :gender) + AND (:status IS NULL OR p.status = :status) + AND (:kycLevel IS NULL OR p.kyc_level = :kycLevel) + AND (:createDateFrom IS NULL OR p.create_date >= :createDateFrom) + AND (:createDateTo IS NULL OR p.create_date <= :createDateTo) + ORDER BY p.create_date + LIMIT :limit OFFSET :offset + """ + ) + fun findByCriteriaAsc( + userId: String?, + firstName: String?, + lastName: String?, + mobile: String?, + email: String?, + identifier: String?, + nationality: NationalityType?, + gender: Gender?, + status: ProfileStatus?, + kycLevel: KycLevel?, + createDateFrom: LocalDateTime?, + createDateTo: LocalDateTime?, + limit: Int, + offset: Int + ): Flux + + @Query( + """ + SELECT * FROM profile + WHERE user_id = :userId + OR ( :mobile IS NOT NULL AND mobile = :mobile ) + OR ( :email IS NOT NULL AND lower(email) = lower(:email) ) + """ + ) + fun findByUserIdOrEmailOrMobile(userId: String, email: String?, mobile: String?): Mono? + + fun findByMobile(mobile: String?): Mono? + + fun findByEmail(email: String?): Mono? + + @Query("UPDATE profile SET kyc_level = :level WHERE user_id = :userId") + fun updateKycLevelByUserId(userId: String, level: String): Mono + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/AddressBookManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/AddressBookManagementImp.kt new file mode 100644 index 000000000..051047cb7 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/AddressBookManagementImp.kt @@ -0,0 +1,58 @@ +package co.nilin.opex.profile.ports.postgres.imp + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.profile.AddressBook +import co.nilin.opex.profile.core.spi.AddressBookPersister +import co.nilin.opex.profile.core.utils.convert +import co.nilin.opex.profile.ports.postgres.dao.AddressBookRepository +import co.nilin.opex.profile.ports.postgres.model.entity.AddressBookModel +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class AddressBookManagementImp( + private val addressBookRepository: AddressBookRepository +) : AddressBookPersister { + + override suspend fun save(addressBook: AddressBook): AddressBook { + val savedAddressBook = + (addressBookRepository.save(addressBook.convert(AddressBookModel::class.java)).awaitFirstOrNull() + ?: throw OpexError.BadRequest.exception("Failed to save address book")) + return savedAddressBook.convert(AddressBook::class.java) + } + + override suspend fun findAll(uuid: String): List { + return addressBookRepository.findAllByUuid(uuid).collectList().awaitFirstOrElse { emptyList() } + } + + override suspend fun update(request: AddressBook): AddressBook { + val addressBook = + addressBookRepository.findById(request.id!!).awaitFirstOrNull() + ?: throw OpexError.AddressBookNotFound.exception() + if (addressBook.uuid == request.uuid) { + addressBook.apply { + name = request.name + address = request.address + addressType = request.addressType + updateDate = LocalDateTime.now() + } + val savedAddressBook = addressBookRepository.save(addressBook).awaitFirstOrNull() + ?: throw OpexError.AddressBookNotFound.exception("Failed to update address book") + return savedAddressBook.convert(AddressBook::class.java) + } else throw OpexError.Forbidden.exception() + } + + override suspend fun delete(uuid: String, id: Long) { + val addressBook = + addressBookRepository.findById(id).awaitFirstOrNull() ?: throw OpexError.AddressBookNotFound.exception() + if (addressBook.uuid == uuid) + addressBookRepository.deleteById(id).awaitFirstOrNull() + else throw OpexError.Forbidden.exception() + } + + override suspend fun findSavedAddress(uuid: String, address: String, addressType: String): AddressBook? { + return addressBookRepository.findByUuidAndAddressAndAddressType(uuid, address, addressType)?.awaitFirstOrNull() + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountManagementImp.kt new file mode 100644 index 000000000..ea726c99e --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountManagementImp.kt @@ -0,0 +1,53 @@ +package co.nilin.opex.profile.ports.postgres.imp + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.profile.BankAccount +import co.nilin.opex.profile.core.spi.BankAccountPersister +import co.nilin.opex.profile.core.utils.convert +import co.nilin.opex.profile.ports.postgres.dao.BankAccountRepository +import co.nilin.opex.profile.ports.postgres.model.entity.BankAccountModel +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.stereotype.Service + +@Service +class BankAccountManagementImp( + private val bankAccountRepository: BankAccountRepository, +) : BankAccountPersister { + + override suspend fun save(bankAccount: BankAccount): BankAccount { + val savedBankAccount = + bankAccountRepository.save(bankAccount.convert(BankAccountModel::class.java)).awaitFirstOrNull() + ?: throw OpexError.BadRequest.exception("Failed to save bank account") + return savedBankAccount.convert(BankAccount::class.java) + } + + override suspend fun findAll(uuid: String): List { + return bankAccountRepository.findAllByUuid(uuid).collectList().awaitFirstOrElse { emptyList() } + } + + override suspend fun findAll( + cardNumber: String?, + iban: String? + ): List { + return bankAccountRepository.findByCardNumberOrIban(cardNumber, iban).collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun findAll( + uuid: String, + cardNumber: String?, + iban: String? + ): List { + return bankAccountRepository.findByUuidAndCardNumberOrIban(uuid, cardNumber, iban).collectList() + .awaitFirstOrElse { emptyList() } + } + + override suspend fun delete(id: Long, uuid: String) { + val bankAccount = + bankAccountRepository.findById(id).awaitFirstOrNull() ?: throw OpexError.BankAccountNotFound.exception() + if (bankAccount.uuid == uuid) + bankAccountRepository.deleteById(id).awaitFirstOrNull() + else throw OpexError.Forbidden.exception() + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/LimitationManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/LimitationManagementImp.kt new file mode 100644 index 000000000..4973cda25 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/LimitationManagementImp.kt @@ -0,0 +1,131 @@ +package co.nilin.opex.profile.ports.postgres.imp + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.limitation.* +import co.nilin.opex.profile.core.spi.LimitationPersister +import co.nilin.opex.profile.ports.postgres.dao.LimitationHistoryRepository +import co.nilin.opex.profile.ports.postgres.dao.ProfileRepository +import co.nilin.opex.profile.ports.postgres.dao.LimitationRepository +import co.nilin.opex.profile.ports.postgres.model.entity.LimitationModel +import co.nilin.opex.profile.core.utils.convert +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class LimitationManagementImp( + private var limitationRepository: LimitationRepository, + private var profileRepository: ProfileRepository, + private var limitationHistoryRepository: LimitationHistoryRepository +) : LimitationPersister { + private val logger = LoggerFactory.getLogger(LimitationManagementImp::class.java) + + @Transactional + override suspend fun updateLimitation(updatePermissionRequest: UpdateLimitationRequest) { + var BreakException = {}; + + //is there particular user? yes + updatePermissionRequest.userId?.let { + profileRepository.findByUserId(updatePermissionRequest.userId!!)?.awaitFirstOrNull() + ?.let { + //set limitations for specific user on some actions + logger.info("set limitations for specific user on some actions") + updatePermissionRequest.actions?.forEach { + if (updatePermissionRequest.updateType == LimitationUpdateType.Revoke) { + limitationRepository.findByLimitationOn(updatePermissionRequest.userId + "_$it") + ?.awaitFirstOrNull() + ?: run { + with(updatePermissionRequest) { + var limit = updatePermissionRequest.convert(LimitationModel::class.java) + limit.actionType = it + limit.limitationOn = userId + "_$it" + limit.createDate = LocalDateTime.now() + limitationRepository.save(limit).awaitFirstOrNull() + + } + } + } else { + logger.info("reset limitations for specific user on some actions") + //reset limitations for specific user on some actions/all + if (updatePermissionRequest.actions?.contains(ActionType.All) == true) { + limitationRepository.deleteByUserId(updatePermissionRequest.userId!!).awaitFirstOrNull() + //todo break + } + limitationRepository.deleteByLimitationOn(updatePermissionRequest.userId + "_$it") + .awaitFirstOrNull() + } + } + } ?: throw OpexError.UserNotFound.exception() + //is there particular user? no + } ?: run { + //set limitations for all users on some actions + logger.info("set limitations for all users on some actions") + updatePermissionRequest.actions?.forEach { + if (updatePermissionRequest.updateType == LimitationUpdateType.Revoke) { + limitationRepository.findByLimitationOn("All_$it")?.awaitFirstOrNull() + ?: run { + with(updatePermissionRequest) { + var limit = updatePermissionRequest.convert(LimitationModel::class.java) + limit.userId = "All" + limit.actionType = it + limit.limitationOn = "All_$it" + limit.createDate = LocalDateTime.now() + limitationRepository.save(limit).awaitFirstOrNull() + } + } + } else { + //reset limitations for all users on some actions/all + logger.info("reset limitations for all users on some actions") + if (updatePermissionRequest.actions?.contains(ActionType.All) == true) { + limitationRepository.deleteAll().awaitFirstOrNull() + //break + } + limitationRepository.deleteByActionType(it).awaitFirstOrNull() + } + } + } + + } + + override suspend fun getLimitation( + userId: String?, + action: ActionType?, + reason: LimitationReason?, + offset: Int?, + size: Int? + ): Flow? { + return limitationRepository.findAllLimitation( + userId, + action, + reason, + offset!!, + size!!, + PageRequest.of(offset, size, Sort.by(Sort.Direction.DESC, "id")) + )?.map { l -> l.convert(Limitation::class.java) } + } + + + override suspend fun getLimitationHistory( + userId: String?, + action: ActionType?, + reason: LimitationReason?, + offset: Int, + size: Int + ): Flow? { + return limitationHistoryRepository.findAllLimitationHistory( + userId, + action, + reason, + offset, + size, + PageRequest.of(offset, size, Sort.by(Sort.Direction.DESC, "id")) + )?.map { l -> l.convert(LimitationHistory::class.java) } + } + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/ProfileApprovalRequestManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/ProfileApprovalRequestManagementImp.kt new file mode 100644 index 000000000..157313d5b --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/ProfileApprovalRequestManagementImp.kt @@ -0,0 +1,89 @@ +package co.nilin.opex.profile.ports.postgres.imp + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.profile.* +import co.nilin.opex.profile.core.spi.ProfileApprovalRequestPersister +import co.nilin.opex.profile.core.utils.convert +import co.nilin.opex.profile.ports.postgres.dao.ProfileApprovalRequestRepository +import co.nilin.opex.profile.ports.postgres.model.entity.ProfileApprovalRequestModel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +@Service +class ProfileApprovalRequestManagementImp( + private var profileApprovalRequestRepository: ProfileApprovalRequestRepository, +) : ProfileApprovalRequestPersister { + + override suspend fun save(request: ProfileApprovalRequest): ProfileApprovalRequest { + profileApprovalRequestRepository.findByUserIdAndStatus( + request.userId, + ProfileApprovalRequestStatus.PENDING + ).awaitFirstOrNull()?.let { + throw OpexError.ProfileApprovalRequestAlreadyExists.exception() + } ?: run { + val requestApprovalRequest: ProfileApprovalRequestModel = + request.convert(ProfileApprovalRequestModel::class.java) + return profileApprovalRequestRepository.save(requestApprovalRequest).awaitFirst() + .convert(ProfileApprovalRequest::class.java) + } + } + + override suspend fun getRequests(request: ProfileApprovalRequestFilter): List { + val createDateFrom = request.createDateFrom?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + } + val createDateTo = request.createDateTo?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + } + + val approvalRequests = + if (request.ascendingByTime) + profileApprovalRequestRepository.findByCriteriaAsc( + request.userId, + request.status, + createDateFrom, + createDateTo, + request.limit, + request.offset + ) else + profileApprovalRequestRepository.findByCriteriaDesc( + request.userId, + request.status, + createDateFrom, + createDateTo, + request.limit, + request.offset + ) + return approvalRequests.map { p -> p.convert(ProfileApprovalAdminResponse::class.java) }.toList() + } + + override suspend fun getRequestById(id: Long): ProfileApprovalAdminResponse { + return profileApprovalRequestRepository.findById(id).map { p -> + p.convert( + ProfileApprovalAdminResponse::class.java + ) + }.awaitFirstOrNull() ?: throw OpexError.ProfileApprovalRequestNotfound.exception() + } + + override suspend fun getRequestByUserId(userId: String): ProfileApprovalUserResponse { + return profileApprovalRequestRepository.findByUserId(userId).map { p -> + p.convert( + ProfileApprovalUserResponse::class.java + ) + }.awaitFirstOrNull() ?: throw OpexError.ProfileApprovalRequestNotfound.exception() + } + + override suspend fun update(request: ProfileApprovalAdminResponse): ProfileApprovalAdminResponse { + val requestApprovalRequest: ProfileApprovalRequestModel = + request.convert(ProfileApprovalRequestModel::class.java) + profileApprovalRequestRepository.save(requestApprovalRequest).awaitFirst() + + return request + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/ProfileManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/ProfileManagementImp.kt new file mode 100644 index 000000000..339fef496 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/ProfileManagementImp.kt @@ -0,0 +1,299 @@ +package co.nilin.opex.profile.ports.postgres.imp + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.kyc.KycLevel +import co.nilin.opex.profile.core.data.kyc.KycLevelDetail +import co.nilin.opex.profile.core.data.kyc.ManualUpdateRequest +import co.nilin.opex.profile.core.data.limitation.ActionType +import co.nilin.opex.profile.core.data.limitation.LimitationReason +import co.nilin.opex.profile.core.data.limitation.LimitationUpdateType +import co.nilin.opex.profile.core.data.limitation.UpdateLimitationRequest +import co.nilin.opex.profile.core.data.profile.* +import co.nilin.opex.profile.core.spi.ProfilePersister +import co.nilin.opex.profile.core.utils.compare +import co.nilin.opex.profile.core.utils.convert +import co.nilin.opex.profile.ports.kyc.imp.KycProxyImp +import co.nilin.opex.profile.ports.postgres.dao.ProfileHistoryRepository +import co.nilin.opex.profile.ports.postgres.dao.ProfileRepository +import co.nilin.opex.profile.ports.postgres.model.entity.ProfileModel +import co.nilin.opex.profile.ports.postgres.utils.RegexPatterns +import co.nilin.opex.profile.ports.postgres.utils.toProfileModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitSingle +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +@Service +class ProfileManagementImp( + private var profileRepository: ProfileRepository, + private var profileHistoryRepository: ProfileHistoryRepository, + private var limitationManagementImp: LimitationManagementImp, + private var kycProxyImp: KycProxyImp, +) : ProfilePersister { + private val logger = LoggerFactory.getLogger(ProfileManagementImp::class.java) + + + override suspend fun completeProfile( + id: String, + data: CompleteProfileRequest, + mobileIdentityMatch: Boolean?, + personalIdentityMatch: Boolean? + ): Mono { + val existingProfile = profileRepository.findByUserId(id)?.awaitFirstOrNull() + ?: throw OpexError.ProfileNotfound.exception() + + val latestProfileByIdentifier = profileRepository.findLatestByIdentifier(data.identifier).awaitFirstOrNull() + if (latestProfileByIdentifier != null && (latestProfileByIdentifier.status != + ProfileStatus.PROFILE_COMPLETED && latestProfileByIdentifier.status != + ProfileStatus.ADMIN_REJECTED) + ) throw OpexError.InvalidProfileData.exception() + + val newProfileModel = data.toProfileModel( + existing = existingProfile, + mobileMatch = mobileIdentityMatch, + personalMatch = personalIdentityMatch + ) + + return profileRepository.save(newProfileModel).map { + it.convert(Profile::class.java) + } + } + + override suspend fun createProfile(data: Profile): Mono { + if (data.email.isNullOrBlank() && data.mobile.isNullOrBlank()) { + throw OpexError.BadRequest.exception("email and mobile is null or empty") + } + profileRepository.findByUserIdOrEmailOrMobile(data.userId!!, data.email, data.mobile)?.awaitFirstOrNull()?.let { + throw OpexError.UserIdAlreadyExists.exception() + } + val profile: ProfileModel = data.convert(ProfileModel::class.java) + val saved = profileRepository.save(profile) + .awaitFirstOrNull() ?: throw OpexError.BadRequest.exception("Failed to save profile") + return Mono.just(saved.convert(Profile::class.java)) + } + + override suspend fun getProfile(userId: String): Profile { + return profileRepository.findByUserId(userId) + .awaitFirstOrNull() + ?.convert(Profile::class.java) + ?: throw OpexError.ProfileNotfound.exception() + } + + override suspend fun getAllProfile(profileRequest: ProfileRequest): List { + val createDateFrom = profileRequest.createDateFrom?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + } + val createDateTo = profileRequest.createDateTo?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + } + + val profiles = if (profileRequest.ascendingByTime) { + profileRepository.findByCriteriaAsc( + userId = profileRequest.userId, + firstName = profileRequest.firstName, + lastName = profileRequest.lastName, + mobile = profileRequest.mobile, + email = profileRequest.email, + identifier = profileRequest.identifier, + nationality = profileRequest.nationality, + gender = profileRequest.gender, + status = profileRequest.status, + kycLevel = profileRequest.kycLevel, + createDateFrom = createDateFrom, + createDateTo = createDateTo, + limit = profileRequest.limit, + offset = profileRequest.offset + ) + } else { + profileRepository.findByCriteriaDesc( + userId = profileRequest.userId, + firstName = profileRequest.firstName, + lastName = profileRequest.lastName, + mobile = profileRequest.mobile, + email = profileRequest.email, + identifier = profileRequest.identifier, + nationality = profileRequest.nationality, + gender = profileRequest.gender, + status = profileRequest.status, + kycLevel = profileRequest.kycLevel, + createDateFrom = createDateFrom, + createDateTo = createDateTo, + limit = profileRequest.limit, + offset = profileRequest.offset + ) + } + return profiles + .map { it.convert(Profile::class.java) } + .collectList() + .awaitSingle() + } + + override suspend fun resolveUsers(uuids: List): Map { + + return profileRepository.findFullNameByUuid(uuids).collectMap( + { it.uuid }, + { it.fullName } + ).awaitSingle() + + } + + override suspend fun getHistory(userId: String, offset: Int, limit: Int): List { + val resp: MutableList = ArrayList() + + profileRepository.findByUserId(userId)?.awaitFirstOrNull() ?: throw OpexError.UserNotFound.exception() + profileHistoryRepository.findByUserId( + userId, + PageRequest.of(offset, limit, Sort.by(Sort.Direction.DESC, "changeRequestDate")) + ) + .map { p -> + p.convert(ProfileHistory::class.java) + } + .toList() + .windowed(2, 1, true) + .forEach { window: List -> + val new = window.first() + val past = window.last() + if (past.userId?.isNotBlank() == true) { + new.updatedItem = new.compare(past) + resp.add(new) + } else + resp.add(past) + } + + return resp.toList() + } + + override suspend fun updateUserLevelAndStatus(userId: String, userLevel: KycLevel, retry: Boolean) { + profileRepository.findByUserId(userId).awaitFirstOrNull()?.let { profileModel -> + profileModel.kycLevel = userLevel + profileRepository.updateKycLevelByUserId(userId, userLevel.name).awaitFirstOrNull() + } ?: run { + if (retry) { + delay(50) + updateUserLevelAndStatus(userId, userLevel, false) + } else + throw OpexError.UserNotFound.exception() + } + } + + override suspend fun validateEmailForUpdate(userId: String, email: String) { + validateEmailFormat(email) + + val profile = profileRepository.findByUserId(userId)?.awaitFirstOrNull() + ?: throw OpexError.ProfileNotfound.exception() + + if (!profile.email.isNullOrEmpty()) + throw OpexError.EmailAlreadySet.exception() + } + + override suspend fun validateMobileForUpdate(userId: String, mobile: String) { + validateMobileFormat(mobile) + + val profile = profileRepository.findByUserId(userId)?.awaitFirstOrNull() + ?: throw OpexError.ProfileNotfound.exception() + + if (!profile.mobile.isNullOrEmpty()) + throw OpexError.MobileAlreadySet.exception() + } + + override suspend fun updateMobile(userId: String, mobile: String) { + if (profileRepository.findByMobile(mobile)?.awaitFirstOrNull() != null) + throw OpexError.MobileAlreadyExists.exception() + val profile = profileRepository.findByUserId(userId)?.awaitFirstOrNull() + ?: throw OpexError.ProfileNotfound.exception() + profile.mobile = mobile + profile.status = ProfileStatus.CONTACT_INFO_COMPLETED + profileRepository.save(profile).awaitFirstOrNull() + } + + override suspend fun updateEmail(userId: String, email: String) { + if (profileRepository.findByEmail(email)?.awaitFirstOrNull() != null) + throw OpexError.EmailAlreadyExists.exception() + val profile = profileRepository.findByUserId(userId)?.awaitFirstOrNull() + ?: throw OpexError.ProfileNotfound.exception() + profile.email = email + profile.status = ProfileStatus.CONTACT_INFO_COMPLETED + profileRepository.save(profile).awaitFirstOrNull() + } + + override suspend fun updateStatus( + userId: String, + status: ProfileStatus + ): Profile { + val profile = profileRepository.findByUserId(userId)?.awaitFirstOrNull() + ?: throw OpexError.ProfileNotfound.exception() + profile.status = status + val saved = profileRepository.save(profile).awaitSingle() + return saved.convert(Profile::class.java) + } + + fun isMajorChanges(oldData: ProfileModel, newData: UpdateProfileRequest): Boolean { + return !oldData.firstName.equals(newData.firstName) || !oldData.lastName.equals(newData.lastName) + } + + fun isContactChanges(oldData: ProfileModel, newData: UpdateProfileRequest): Boolean { + // return oldData.email != newData.email || + return !oldData.mobile.equals(newData.mobile) + } + + suspend fun applyMajorChangesRequirements(oldData: ProfileModel, newData: UpdateProfileRequest): KycLevel? { + //todo + //read from panel + val newKycLevel = KycLevel.LEVEL_1 + + updateKycLevel(userId = oldData.userId!!, kycLevel = newKycLevel, LimitationReason.MajorProfileChange.name) + limitationManagementImp.updateLimitation( + UpdateLimitationRequest( + oldData.userId, arrayOf( + + ActionType.CashOut, ActionType.Withdraw + ).asList(), null, LimitationUpdateType.Revoke, null, null, LimitationReason.MajorProfileChange + ) + ) + + return newKycLevel + } + + suspend fun applyContactChangesRequirements(oldData: ProfileModel, newData: UpdateProfileRequest): KycLevel? { + //todo + //read from panel + val newKycLevel = KycLevel.LEVEL_1 + + updateKycLevel(userId = oldData.userId!!, kycLevel = newKycLevel, LimitationReason.MajorProfileChange.name) + limitationManagementImp.updateLimitation( + UpdateLimitationRequest( + oldData.userId, arrayOf( + + ActionType.Withdraw + ).asList(), null, LimitationUpdateType.Revoke, null, null, LimitationReason.ContactProfileChange + ) + ) + return newKycLevel + } + + suspend fun updateKycLevel(userId: String, kycLevel: KycLevel, reason: String?) { + val kycLevelDetail = + if (kycLevel == KycLevel.LEVEL_1) KycLevelDetail.ManualUpdateLevel1 else KycLevelDetail.ManualUpdateLevel3 + kycProxyImp.updateKycLevel(ManualUpdateRequest(kycLevelDetail).apply { + this.userId = userId + this.description = reason + }) + } + + private fun validateEmailFormat(email: String) { + if (!RegexPatterns.EMAIL.matches(email)) throw OpexError.InvalidEmail.exception() + } + + private fun validateMobileFormat(mobile: String) { + if (!RegexPatterns.MOBILE.matches(mobile)) throw OpexError.InvalidMobile.exception() + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/HistoryTracker.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/HistoryTracker.kt new file mode 100644 index 000000000..7e44b03ae --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/HistoryTracker.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.postgres.model + +import java.util.* + +data class HistoryTracker( + var originalDataId: Long?, + var issuer: String?, + var changeRequestDate: Date?, + var changeRequestType: String? +) \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/AddressBook.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/AddressBook.kt new file mode 100644 index 000000000..81ed567da --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/AddressBook.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.profile.ports.postgres.model.base + +import java.time.LocalDateTime + +open class AddressBook { + lateinit var uuid: String + lateinit var name: String + lateinit var address: String + lateinit var addressType: String + lateinit var createDate: LocalDateTime + var updateDate: LocalDateTime? = null +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/BankAccount.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/BankAccount.kt new file mode 100644 index 000000000..d383231d5 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/BankAccount.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.profile.ports.postgres.model.base + +import co.nilin.opex.profile.core.data.profile.BankAccountStatus +import java.time.LocalDateTime + +open class BankAccount { + lateinit var uuid: String + var name: String? = null + var cardNumber: String? = null + var iban: String? = null + var accountNumber: String? = null + var bank: String? = null + lateinit var status: BankAccountStatus + lateinit var createDate: LocalDateTime + var updateDate: LocalDateTime? = null + var creator: String? = null + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/Limitation.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/Limitation.kt new file mode 100644 index 000000000..b63ccbc37 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/Limitation.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.profile.ports.postgres.model.base + +import co.nilin.opex.profile.core.data.limitation.ActionType +import co.nilin.opex.profile.core.data.limitation.LimitationReason +import java.time.LocalDateTime +import java.util.* + +open class Limitation { + lateinit var userId: String; + var actionType: ActionType? = null; + var createDate: LocalDateTime? = null; + var expTime: Long? = null; + var detail: String? = null + var limitationOn: String? = null + var description: String? = null + var reason: LimitationReason? = null +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/Profile.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/Profile.kt new file mode 100644 index 000000000..0cacb9456 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/Profile.kt @@ -0,0 +1,30 @@ +package co.nilin.opex.profile.ports.postgres.model.base + +import co.nilin.opex.profile.core.data.kyc.KycLevel +import co.nilin.opex.profile.core.data.profile.Gender +import co.nilin.opex.profile.core.data.profile.NationalityType +import co.nilin.opex.profile.core.data.profile.ProfileStatus +import java.time.LocalDateTime + +open class Profile { + var email: String? = null + lateinit var userId: String + var firstName: String? = null + var lastName: String? = null + var address: String? = null + var mobile: String? = null + var telephone: String? = null + var postalCode: String? = null + var nationality: NationalityType? = null + var identifier: String? = null + var gender: Gender? = null + lateinit var birthDate: LocalDateTime + var status: ProfileStatus? = null + var createDate: LocalDateTime? = null + var lastUpdateDate: LocalDateTime? = null + var creator: String? = null + var kycLevel: KycLevel? = KycLevel.LEVEL_1 + var mobileIdentityMatch: Boolean? = null + var personalIdentityMatch: Boolean? = null + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/ProfileApprovalRequest.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/ProfileApprovalRequest.kt new file mode 100644 index 000000000..2e094b0e3 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/ProfileApprovalRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.profile.ports.postgres.model.base + +import co.nilin.opex.profile.core.data.profile.ProfileApprovalRequestStatus +import java.time.LocalDateTime + +open class ProfileApprovalRequest { + lateinit var userId: String + var status: ProfileApprovalRequestStatus? = ProfileApprovalRequestStatus.PENDING + var createDate: LocalDateTime? = null + var updateDate: LocalDateTime? = null + var updater: String? = null + var description: String? = null +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/AddressBookModel.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/AddressBookModel.kt new file mode 100644 index 000000000..becbda900 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/AddressBookModel.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.postgres.model.entity + +import co.nilin.opex.profile.ports.postgres.model.base.AddressBook +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("address_book") +data class AddressBookModel( + @Id var id: Long +) : AddressBook() diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/BankAccountModel.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/BankAccountModel.kt new file mode 100644 index 000000000..70a4768b1 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/BankAccountModel.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.postgres.model.entity + +import co.nilin.opex.profile.ports.postgres.model.base.BankAccount +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("bank_account") +data class BankAccountModel( + @Id var id: Long +) : BankAccount() diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/LimitationModel.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/LimitationModel.kt new file mode 100644 index 000000000..ac0782354 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/LimitationModel.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.profile.ports.postgres.model.entity + +import co.nilin.opex.profile.ports.postgres.model.base.Limitation +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("limitation") +data class LimitationModel(@Id var id: Long) : Limitation() diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/ProfileApprovalRequestModel.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/ProfileApprovalRequestModel.kt new file mode 100644 index 000000000..599910755 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/ProfileApprovalRequestModel.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.postgres.model.entity + +import co.nilin.opex.profile.ports.postgres.model.base.ProfileApprovalRequest +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("profile_approval_request") +data class ProfileApprovalRequestModel( + @Id var id: Long +) : ProfileApprovalRequest() \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/ProfileModel.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/ProfileModel.kt new file mode 100644 index 000000000..03db781ef --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/ProfileModel.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.postgres.model.entity + +import co.nilin.opex.profile.ports.postgres.model.base.Profile +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("profile") +data class ProfileModel( + @Id var id: Long +) : Profile() diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/history/LimitationHistory.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/history/LimitationHistory.kt new file mode 100644 index 000000000..f9bc18518 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/history/LimitationHistory.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.profile.ports.postgres.model.history + +import co.nilin.opex.profile.ports.postgres.model.base.Limitation +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime +import java.util.* + +@Table("limitation_history") +data class LimitationHistory( + @Id + var id: Long, + var issuer: String?, + var changeRequestDate: LocalDateTime?, + var changeRequestType: String? +) : Limitation() \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/history/ProfileHistory.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/history/ProfileHistory.kt new file mode 100644 index 000000000..2cef13f11 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/history/ProfileHistory.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.profile.ports.postgres.model.history + +import co.nilin.opex.profile.ports.postgres.model.base.Profile +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime +import java.util.* + +@Table("profile_history") +data class ProfileHistory( + @Id + var id: Long, + var originalDataId: Long?, + var issuer: String?, + var changeRequestDate: LocalDateTime?, + var changeRequestType: String? +) : Profile() + + + + diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/Convertor.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/Convertor.kt new file mode 100644 index 000000000..622a68ad9 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/Convertor.kt @@ -0,0 +1,39 @@ +package co.nilin.opex.profile.ports.postgres.utils + +import co.nilin.opex.profile.core.data.profile.CompleteProfileRequest +import co.nilin.opex.profile.core.data.profile.ProfileStatus +import co.nilin.opex.profile.core.utils.gson +import co.nilin.opex.profile.ports.postgres.model.entity.ProfileModel +import java.time.LocalDateTime + +fun Any.convert(classOfT: Class): T = gson.fromJson(gson.toJson(this), classOfT) + +fun CompleteProfileRequest.toProfileModel( + existing: ProfileModel, + mobileMatch: Boolean?, + personalMatch: Boolean? +): ProfileModel { + return ProfileModel(id = existing.id).apply { + userId = existing.userId + email = existing.email + mobile = existing.mobile + status = ProfileStatus.PROFILE_COMPLETED + createDate = existing.createDate + creator = existing.creator + kycLevel = existing.kycLevel + lastUpdateDate = LocalDateTime.now() + + firstName = this@toProfileModel.firstName + lastName = this@toProfileModel.lastName + address = this@toProfileModel.address + telephone = this@toProfileModel.telephone + postalCode = this@toProfileModel.postalCode + nationality = this@toProfileModel.nationality + identifier = this@toProfileModel.identifier + gender = this@toProfileModel.gender + birthDate = this@toProfileModel.birthDate.asLocalDateTime() + + mobileIdentityMatch = mobileMatch + personalIdentityMatch = personalMatch + } +} diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/Extensions.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/Extensions.kt new file mode 100644 index 000000000..6806c6f99 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/Extensions.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.profile.ports.postgres.utils + + +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +fun LocalDateTime.asDate(): Date { + return Date.from(atZone(ZoneId.systemDefault()).toInstant()) +} + +fun Date.asLocalDateTime(): LocalDateTime { + return LocalDateTime.ofInstant(toInstant(), ZoneId.systemDefault()) +} + +fun Long.asLocalDateTime(): LocalDateTime { + return LocalDateTime.ofInstant(Date(this).toInstant(), ZoneId.systemDefault()) +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/RegexPatterns.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/RegexPatterns.kt new file mode 100644 index 000000000..b60b2a57a --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/utils/RegexPatterns.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.profile.ports.postgres.utils + +object RegexPatterns { + val EMAIL = Regex("""^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$""") + val MOBILE = Regex("""^09\d{9}$""") + val IBAN = Regex("""^IR[0-9]{24}$""") +} \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V1__init_schema.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 000000000..d762a3f5b --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1 @@ +CREATE TABLE IF NOT EXISTS test(id SERIAL PRIMARY KEY); \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V2__create_tables.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V2__create_tables.sql new file mode 100644 index 000000000..dc56ba7f8 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V2__create_tables.sql @@ -0,0 +1,300 @@ +CREATE TABLE IF NOT EXISTS profile +( + id SERIAL PRIMARY KEY, + email VARCHAR(100) UNIQUE, + last_name VARCHAR(256), + user_id VARCHAR(100) NOT NULL UNIQUE, + create_date TIMESTAMP, + identifier VARCHAR(100) UNIQUE, + address VARCHAR(256), + first_name VARCHAR(256), + telephone VARCHAR(256), + mobile VARCHAR(256) UNIQUE, + nationality VARCHAR(256), + gender VARCHAR(50), + birth_date TIMESTAMP, + status VARCHAR(100), + postal_code VARCHAR(100), + creator VARCHAR(100), + last_update_date TIMESTAMP DEFAULT CURRENT_DATE, + kyc_level varchar(100), + verification_status VARCHAR(255), + personal_identity_match BOOLEAN, + mobile_identity_match BOOLEAN +); + +CREATE TABLE IF NOT EXISTS profile_history +( + id SERIAL PRIMARY KEY, + email VARCHAR(100), + last_name VARCHAR(256), + user_id VARCHAR(100) NOT NULL, + create_date TIMESTAMP, + identifier VARCHAR(100), + address VARCHAR(256), + first_name VARCHAR(256), + telephone VARCHAR(256), + mobile VARCHAR(256), + nationality VARCHAR(256), + gender VARCHAR(50), + birth_date TIMESTAMP, + status VARCHAR(100), + last_update_date TIMESTAMP, + original_data_id VARCHAR(100) NOT NULL, + creator VARCHAR(100), + issuer VARCHAR(100), + postal_code VARCHAR(100), + change_request_date TIMESTAMP, + change_request_type VARCHAR(100), + kyc_level varchar(100), + personal_identity_match BOOLEAN, + mobile_identity_match BOOLEAN, + verification_status VARCHAR(255) + +); + + +CREATE TABLE IF NOT EXISTS limitation +( + id SERIAL PRIMARY KEY, + user_id VARCHAR(100) NOT NULL, + action_type VARCHAR(100), + create_date TIMESTAMP, + exp_time VARCHAR(100), + detail VARCHAR(100), + limitation_on VARCHAR(100) UNIQUE NOT NULL, + description VARCHAR(100), + reason VARCHAR(100) +); + + +CREATE TABLE IF NOT EXISTS limitation_history +( + id SERIAL PRIMARY KEY, + user_id VARCHAR(100) NOT NULL, + action_type VARCHAR(100), + create_date TIMESTAMP, + exp_time VARCHAR(100), + detail VARCHAR(100), + issuer VARCHAR(100), + change_request_date TIMESTAMP, + change_request_type VARCHAR(100), + limitation_on VARCHAR(100), + description VARCHAR(100), + reason VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS linked_bank_account +( + id SERIAL PRIMARY KEY, + user_id VARCHAR(100) NOT NULL, + bank_account_type VARCHAR(100), + register_date TIMESTAMP, + verified_date TIMESTAMP, + enabled BOOLEAN, + verified BOOLEAN, + verifier VARCHAR(100), + number VARCHAR(100), + account_id VARCHAR(100) UNIQUE, + description VARCHAR(100) +); + +ALTER TABLE linked_bank_account + DROP CONSTRAINT IF EXISTS unique_account; +ALTER TABLE linked_bank_account + ADD CONSTRAINT unique_account UNIQUE (user_id, number); + + +CREATE TABLE IF NOT EXISTS linked_bank_account_history +( + id SERIAL PRIMARY KEY, + user_id VARCHAR(100) NOT NULL, + bank_account_type VARCHAR(100), + register_date TIMESTAMP, + verified_date TIMESTAMP, + enabled BOOLEAN, + verifid BOOLEAN, + verifier VARCHAR(100), + number VARCHAR(100), + account_id VARCHAR(100), + description VARCHAR(100), + change_request_date TIMESTAMP, + change_request_type VARCHAR(100) +); + +DROP TRIGGER IF EXISTS profile_log_update on public.profile; +DROP TRIGGER IF EXISTS profile_log_delete on public.profile; + +DROP TRIGGER IF EXISTS limitation_log_update on public.limitation; +DROP TRIGGER IF EXISTS limitation_log_delete on public.limitation; + +DROP TRIGGER IF EXISTS linked_account_log_update on public.linked_bank_account; +DROP TRIGGER IF EXISTS linked_account_log_delete on public.linked_bank_account; + + +CREATE OR REPLACE FUNCTION triger_function() RETURNS TRIGGER AS +$BODY$ +BEGIN + INSERT INTO public.profile_history (original_data_id, change_request_date, change_request_type, email, user_id, + create_date, identifier, address, first_name, last_name, mobile, telephone, + nationality, gender, birth_date, status, postal_code, creator, kyc_level,personal_identity_match,mobile_identity_match) + VALUES (OLD.id, now(), 'UPDATE', OLD.email, OLD.user_id, OLD.create_date, OLD.identifier, OLD.address, + OLD.first_name, + OLD.last_name, OLD.mobile, OLD.telephone, OLD.nationality, OLD.gender, OLD.birth_date, OLD.status, + OLD.postal_code, OLD.creator, OLD.kyc_level,OLD.personal_identity_match,OLD.mobile_identity_match); + RETURN NULL; +END; +$BODY$ language plpgsql; + + + +CREATE OR REPLACE FUNCTION triger_delete_function() RETURNS TRIGGER AS +$BODY$ +BEGIN + INSERT INTO public.profile_history (original_data_id, change_request_date, change_request_type, email, user_id, + create_date, identifier, address, first_name, last_name, mobile, telephone, + nationality, gender, birth_date, status, postal_code, creator, kyc_level,personal_identity_match,mobile_identity_match) + VALUES (OLD.id, now(), 'DELETE', OLD.email, OLD.user_id, OLD.create_date, OLD.identifier, OLD.address, + OLD.first_name, + OLD.last_name, OLD.mobile, OLD.telephone, OLD.nationality, OLD.gender, OLD.birth_date, OLD.status, + OLD.postal_code, OLD.creator, OLD.kyc_level,OLD.personal_identity_match,OLD.mobile_identity_match); + RETURN NULL; +END; +$BODY$ language plpgsql; + + + +CREATE OR REPLACE FUNCTION triger_limitation_function() RETURNS TRIGGER AS +$BODY$ +BEGIN + INSERT INTO public.limitation_history (change_request_date, change_request_type, user_id, create_date, action_type, + exp_time, detail, limitation_on, description, reason) + VALUES (now(), 'UPDATE', OLD.user_id, OLD.create_date, OLD.action_type, OLD.exp_time, OLD.detail, OLD.limitation_on, + OLD.description, OLD.reason); + RETURN NULL; +END; +$BODY$ language plpgsql; + + + +CREATE OR REPLACE FUNCTION triger_delete_limitation_function() RETURNS TRIGGER AS +$BODY$ +BEGIN + INSERT INTO public.limitation_history (change_request_date, change_request_type, user_id, create_date, action_type, + exp_time, detail, limitation_on, description, reason) + VALUES (now(), 'DELETE', OLD.user_id, OLD.create_date, OLD.action_type, OLD.exp_time, OLD.detail, OLD.limitation_on, + OLD.description, OLD.reason); + RETURN NULL; +END; +$BODY$ language plpgsql; + + + +CREATE OR REPLACE FUNCTION triger_linked_account_function() RETURNS TRIGGER AS +$BODY$ +BEGIN + INSERT INTO public.linked_bank_account_history (change_request_date, change_request_type, user_id, verified_date, + enabled, verified, verifier, number, description, account_id) + VALUES (now(), 'UPDATE', OLD.user_id, OLD.verified_date, OLD.enabled, OLD.verified, OLD.verifier, OLD.number, + OLD.description, OLD.account_id); + RETURN NULL; +END; +$BODY$ language plpgsql; + + +CREATE OR REPLACE FUNCTION triger_delete_linked_account_function() RETURNS TRIGGER AS +$BODY$ +BEGIN + INSERT INTO public.linked_bank_account_history (change_request_date, change_request_type, user_id, verified_date, + enabled, verified, verifier, number, description, account_id) + VALUES (now(), 'DELETE', OLD.user_id, OLD.verified_date, OLD.enabled, OLD.verified, OLD.verifier, OLD.number, + OLD.description, OLD.account_id); + RETURN NULL; +END; +$BODY$ language plpgsql; + + + +CREATE TRIGGER profile_log_update + AFTER UPDATE + ON profile + FOR EACH ROW +EXECUTE PROCEDURE triger_function(); +CREATE TRIGGER profile_log_delete + AFTER DELETE + ON profile + FOR EACH ROW +EXECUTE PROCEDURE triger_delete_function(); + + +CREATE TRIGGER limitation_log_update + AFTER UPDATE + ON limitation + FOR EACH ROW +EXECUTE PROCEDURE triger_limitation_function(); +CREATE TRIGGER limitation_log_delete + AFTER DELETE + ON limitation + FOR EACH ROW +EXECUTE PROCEDURE triger_delete_limitation_function(); + + + +CREATE TRIGGER linked_account_log_update + AFTER UPDATE + ON linked_bank_account + FOR EACH ROW +EXECUTE PROCEDURE triger_linked_account_function(); +CREATE TRIGGER linked_account_log_delete + AFTER DELETE + ON linked_bank_account + FOR EACH ROW +EXECUTE PROCEDURE triger_delete_linked_account_function(); + +CREATE TABLE IF NOT EXISTS profile_approval_request +( + id SERIAL PRIMARY KEY, + profile_id BIGINT NOT NULL, + status VARCHAR(100) NOT NULL, + create_date TIMESTAMP, + update_date TIMESTAMP, + updater VARCHAR(100), + description VARCHAR(255), + UNIQUE (profile_id, status) +); + +DO +$$ + BEGIN + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'profile' AND column_name = 'verification_status') THEN ALTER TABLE profile + ADD COLUMN verification_status VARCHAR(255); + END IF; + END +$$; + +DO +$$ + BEGIN + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'profile_history' + AND column_name = 'verification_status') THEN ALTER TABLE profile_history + ADD COLUMN verification_status VARCHAR(255); + END IF; + END +$$; + +DO +$$ + BEGIN + IF EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'profile_history' + AND column_name = 'gender' + AND data_type != 'character varying') THEN ALTER TABLE profile_history + ALTER COLUMN gender SET DATA TYPE VARCHAR(50); + END IF; + END +$$; \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V3__drop_columns.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V3__drop_columns.sql new file mode 100644 index 000000000..c72265e8c --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V3__drop_columns.sql @@ -0,0 +1,5 @@ +ALTER TABLE profile + DROP COLUMN IF EXISTS verification_status; + +ALTER TABLE profile_history + DROP COLUMN IF EXISTS verification_status; \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V4__financial_accounts.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V4__financial_accounts.sql new file mode 100644 index 000000000..b7d534749 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V4__financial_accounts.sql @@ -0,0 +1,36 @@ +drop function IF EXISTS triger_linked_account_function(); +drop function IF EXISTS triger_delete_linked_account_function(); + +DROP TABLE IF EXISTS linked_bank_account_history; +DROP TABLE IF EXISTS linked_bank_account; + +CREATE TABLE IF NOT EXISTS bank_account +( + id SERIAL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL, + name VARCHAR(100), + card_number VARCHAR(16), + iban VARCHAR(26), + account_number VARCHAR(24), + bank VARCHAR(20), + status VARCHAR(20) NOT NULL, + create_date TIMESTAMP NOT NULL, + update_date TIMESTAMP, + creator VARCHAR(100), + CHECK (card_number IS NOT NULL OR iban IS NOT NULL), + UNIQUE (uuid, card_number, iban, account_number, status) +); +CREATE INDEX IF NOT EXISTS idx_bank_account_uuid ON bank_account (uuid); + +CREATE TABLE IF NOT EXISTS address_book +( + id SERIAL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL, + address VARCHAR(72) NOT NULL, + name VARCHAR(100) NOT NULL, + address_type VARCHAR(20) NOT NULL, + create_date TIMESTAMP NOT NULL, + update_date TIMESTAMP, + UNIQUE (uuid, address, address_type) +); +CREATE INDEX IF NOT EXISTS idx_address_book_uuid ON address_book (uuid); diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V5__update_approval_request.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V5__update_approval_request.sql new file mode 100644 index 000000000..c9045f991 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V5__update_approval_request.sql @@ -0,0 +1,10 @@ +ALTER TABLE profile_approval_request + ADD COLUMN user_id VARCHAR(36); + +UPDATE profile_approval_request par +SET user_id = p.user_id +FROM profile p +WHERE p.id = par.profile_id; + +ALTER TABLE profile_approval_request + DROP COLUMN profile_id; \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V6__create_version_column_for_profile.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V6__create_version_column_for_profile.sql new file mode 100644 index 000000000..d466aaa44 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V6__create_version_column_for_profile.sql @@ -0,0 +1,2 @@ +ALTER TABLE profile + ADD COLUMN version BIGINT default 0; diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V7__drop_profile_identifier_unique_constraint.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V7__drop_profile_identifier_unique_constraint.sql new file mode 100644 index 000000000..5bfa0ebb8 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V7__drop_profile_identifier_unique_constraint.sql @@ -0,0 +1,2 @@ +ALTER TABLE profile + DROP CONSTRAINT unique_identifier; \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/test/kotlin/co/nilin/opex/profile/ports/postgres/ProfilePostgressApplicationTests.kt b/profile/profile-ports/profile-postgres/src/test/kotlin/co/nilin/opex/profile/ports/postgres/ProfilePostgressApplicationTests.kt new file mode 100644 index 000000000..b1fecce81 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/test/kotlin/co/nilin/opex/profile/ports/postgres/ProfilePostgressApplicationTests.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.postgres + +//@SpringBootTest +//class ProfilePostgressApplicationTests { +// +// @Test +// fun contextLoads() { +// } +// +//} diff --git a/profile/profile-ports/profile-submitter-kafka/.gitignore b/profile/profile-ports/profile-submitter-kafka/.gitignore new file mode 100644 index 000000000..549e00a2a --- /dev/null +++ b/profile/profile-ports/profile-submitter-kafka/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/profile/profile-ports/profile-submitter-kafka/pom.xml b/profile/profile-ports/profile-submitter-kafka/pom.xml new file mode 100644 index 000000000..3d6909fc1 --- /dev/null +++ b/profile/profile-ports/profile-submitter-kafka/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + co.nilin.opex + profile + 1.0.1-beta.7 + ../../pom.xml + + co.nilin.opex.profile.ports + profile-submitter-kafka + profile-submitter-kafka + profile-kafka + + + + org.springframework.boot + spring-boot-starter + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.springframework.kafka + spring-kafka + + + org.springframework.boot + spring-boot-starter-test + test + + + co.nilin.opex.profile + profile-core + + + + + diff --git a/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaProducerConfig.kt b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaProducerConfig.kt new file mode 100644 index 000000000..5d6fb587f --- /dev/null +++ b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaProducerConfig.kt @@ -0,0 +1,57 @@ +package co.nilin.opex.profile.ports.kafka.config + + +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent +import co.nilin.opex.profile.core.data.event.ProfileUpdatedEvent +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringSerializer +import org.slf4j.LoggerFactory +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.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonDeserializer +import org.springframework.kafka.support.serializer.JsonSerializer + +@Configuration +class KafkaProducerConfig { + private val logger = LoggerFactory.getLogger(KafkaProducerConfig::class.java) + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Bean("producerConfigs") + fun producerConfigs(): Map { + return mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, + ProducerConfig.ACKS_CONFIG to "all", + JsonDeserializer.TRUSTED_PACKAGES to "co.nilin.opex.*", + JsonSerializer.TYPE_MAPPINGS to "kyc_level_updated_event:co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent,profile_updated_event:co.nilin.opex.profile.core.data.event.ProfileUpdatedEvent" + ) + } + + @Bean("kycEventProducerFactory") + fun kycProducerFactory(@Qualifier("producerConfigs") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean("kycEventKafkaTemplate") + fun kycKafkaTemplate(@Qualifier("kycEventProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } + + @Bean("profileUpdatedEventProducerFactory") + fun profileProducerFactory(@Qualifier("producerConfigs") producerConfigs: Map): ProducerFactory { + return DefaultKafkaProducerFactory(producerConfigs) + } + + @Bean("profileUpdatedKafkaTemplate") + fun profileKafkaTemplate(@Qualifier("profileUpdatedEventProducerFactory") producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaTopicConfig.kt b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaTopicConfig.kt new file mode 100644 index 000000000..4267d70c6 --- /dev/null +++ b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/config/KafkaTopicConfig.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.profile.ports.kafka.config + +import org.apache.kafka.clients.admin.NewTopic +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Configuration +import org.springframework.context.support.GenericApplicationContext +import org.springframework.kafka.config.TopicBuilder +import java.util.function.Supplier + +@Configuration +class KafkaTopicConfig { + + @Autowired + fun createTopics(applicationContext: GenericApplicationContext) { + applicationContext.registerBean("topic_kyc_level_updated", NewTopic::class.java, Supplier { + TopicBuilder.name("kyc_level_updated") + .partitions(1) + .replicas(1) + .build() + }) + applicationContext.registerBean("topic_profile_updated", NewTopic::class.java, Supplier { + TopicBuilder.name("profile_updated") + .partitions(1) + .replicas(1) + .build() + }) + } +} \ No newline at end of file diff --git a/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/publisher/KycLevelUpdatedSubmitter.kt b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/publisher/KycLevelUpdatedSubmitter.kt new file mode 100644 index 000000000..b5564ed8a --- /dev/null +++ b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/publisher/KycLevelUpdatedSubmitter.kt @@ -0,0 +1,36 @@ +package co.nilin.opex.profile.ports.kafka.publisher + + +import co.nilin.opex.profile.core.data.event.KycLevelUpdatedEvent +import co.nilin.opex.profile.core.spi.KycLevelUpdatedPublisher +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +@Component +class KycLevelUpdatedSubmitter( + @Qualifier("kycEventKafkaTemplate") private val kafkaTemplate: KafkaTemplate, +) : KycLevelUpdatedPublisher { + + private val logger = LoggerFactory.getLogger(KycLevelUpdatedSubmitter::class.java) + + val topic = "kyc_level_updated" + + override suspend fun publish(event: KycLevelUpdatedEvent): Unit = suspendCoroutine { cont -> + logger.info("Submitting kycLevelUpdated") + + val sendFuture = kafkaTemplate.send(topic, event) + sendFuture.addCallback({ + cont.resume(Unit) + }, { + logger.error("Error submitting kycLevelChange", it) + cont.resumeWithException(it) + }) + } + + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/publisher/ProfileUpdatedSubmitter.kt b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/publisher/ProfileUpdatedSubmitter.kt new file mode 100644 index 000000000..44d5c484f --- /dev/null +++ b/profile/profile-ports/profile-submitter-kafka/src/main/kotlin/co/nilin/opex/profile/ports/kafka/publisher/ProfileUpdatedSubmitter.kt @@ -0,0 +1,36 @@ +package co.nilin.opex.profile.ports.kafka.publisher + + +import co.nilin.opex.profile.core.data.event.ProfileUpdatedEvent +import co.nilin.opex.profile.core.spi.ProfileUpdatedPublisher +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +@Component +class ProfileUpdatedSubmitter( + @Qualifier("profileUpdatedKafkaTemplate") private val kafkaTemplate: KafkaTemplate, +) : ProfileUpdatedPublisher { + + private val logger = LoggerFactory.getLogger(ProfileUpdatedSubmitter::class.java) + + val topic = "profile_updated" + + override suspend fun publish(event: ProfileUpdatedEvent): Unit = suspendCoroutine { cont -> + logger.info("Submitting ProfileUpdatedEvent") + + val sendFuture = kafkaTemplate.send(topic, event) + sendFuture.addCallback({ + cont.resume(Unit) + }, { + logger.error("Error submitting ProfileUpdatedEvent", it) + cont.resumeWithException(it) + }) + } + + +} \ No newline at end of file diff --git a/profile/profile-ports/profile-submitter-kafka/src/main/resources/application.properties b/profile/profile-ports/profile-submitter-kafka/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/profile/profile-ports/profile-submitter-kafka/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/user-management/keycloak-gateway/Dockerfile b/user-management/keycloak-gateway/Dockerfile index 8afbe8745..de5566cc4 100644 --- a/user-management/keycloak-gateway/Dockerfile +++ b/user-management/keycloak-gateway/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11 +FROM eclipse-temurin:17-jdk ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar COPY target/classes/opex-master-realm.json opex-master-realm.json diff --git a/user-management/keycloak-gateway/pom.xml b/user-management/keycloak-gateway/pom.xml index 94be9f19c..9023304ca 100644 --- a/user-management/keycloak-gateway/pom.xml +++ b/user-management/keycloak-gateway/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/KeycloakGatewayApp.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/KeycloakGatewayApp.kt index 67ec6be6a..be3a628dc 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/KeycloakGatewayApp.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/KeycloakGatewayApp.kt @@ -7,7 +7,6 @@ import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfigurati import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.context.annotation.ComponentScan -import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication(exclude = [LiquibaseAutoConfiguration::class]) diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/EmbeddedKeycloakConfig.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/EmbeddedKeycloakConfig.kt index dfef8a24e..415a7cb60 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/EmbeddedKeycloakConfig.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/EmbeddedKeycloakConfig.kt @@ -2,12 +2,8 @@ package co.nilin.opex.auth.gateway.config import org.jboss.resteasy.plugins.server.servlet.HttpServlet30Dispatcher import org.jboss.resteasy.plugins.server.servlet.ResteasyContextParameters -import org.keycloak.admin.client.Keycloak -import org.keycloak.admin.client.resource.RealmResource import org.keycloak.connections.jpa.updater.liquibase.ThreadLocalSessionContext import org.keycloak.models.KeycloakSession -import org.keycloak.services.DefaultKeycloakSessionFactory -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.boot.web.servlet.ServletRegistrationBean import org.springframework.context.annotation.Bean @@ -54,14 +50,13 @@ class EmbeddedKeycloakConfig { filter.addUrlPatterns(keycloakServerProperties.contextPath + "/*") return filter } + @Bean fun keycloakSession(): KeycloakSession? { - return ThreadLocalSessionContext.getCurrentSession(); + return ThreadLocalSessionContext.getCurrentSession(); } - - @Throws(NamingException::class) private fun mockJndiEnvironment() { NamingManager.setInitialContextFactoryBuilder { diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/KafkaConfig.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/KafkaConfig.kt index a5eca210d..55f855b53 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/KafkaConfig.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/config/KafkaConfig.kt @@ -28,11 +28,11 @@ class KafkaConfig { @Bean("authProducerConfigs") fun producerConfigs(): Map { return mapOf( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, - ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, - ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, - ProducerConfig.ACKS_CONFIG to "all", - JsonSerializer.TYPE_MAPPINGS to "user_created_event:co.nilin.opex.auth.gateway.model.UserCreatedEvent" + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, + ProducerConfig.ACKS_CONFIG to "all", + JsonSerializer.TYPE_MAPPINGS to "user_created_event:co.nilin.opex.auth.gateway.model.UserCreatedEvent" ) } @@ -48,19 +48,19 @@ class KafkaConfig { @Autowired fun createUserCreatedTopics(applicationContext: GenericApplicationContext) { - applicationContext.registerBean("topic_auth_user_created", NewTopic::class.java, Supplier { - TopicBuilder.name("auth_user_created") - .partitions(1) - .replicas(1) - .build() + applicationContext.registerBean("topic_auth", NewTopic::class.java, Supplier { + TopicBuilder.name("auth") + .partitions(1) + .replicas(1) + .build() }) } @Autowired fun configureEventListeners( - kycLevelUpdatedKafkaListener: KycLevelUpdatedKafkaListener, - kycLevelUpdatedEventListener: KycLevelUpdatedEventListener + kycLevelUpdatedKafkaListener: KycLevelUpdatedKafkaListener, + kycLevelUpdatedEventListener: KycLevelUpdatedEventListener ) { kycLevelUpdatedKafkaListener.addEventListener(kycLevelUpdatedEventListener) diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/CaptchaType.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/CaptchaType.kt new file mode 100644 index 000000000..61d4ef57b --- /dev/null +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/CaptchaType.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.auth.gateway.data + +enum class CaptchaType { + INTERNAL, ARCAPTCHA, HCAPTCHA +} \ No newline at end of file diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/ChangePasswordRequest.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/ChangePasswordRequest.kt index df866b20f..2b1716dec 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/ChangePasswordRequest.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/ChangePasswordRequest.kt @@ -1,10 +1,10 @@ package co.nilin.opex.auth.gateway.data -class ChangePasswordRequest{ +class ChangePasswordRequest { - var password: String?=null - var newPassword: String?=null - var confirmation: String?=null + var password: String? = null + var newPassword: String? = null + var confirmation: String? = null constructor() diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt index a07914a5a..ab913476b 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt @@ -6,6 +6,7 @@ class RegisterUserRequest { var lastName: String? = null var email: String? = null var captchaAnswer: String? = null + var captchaType: CaptchaType? = CaptchaType.INTERNAL var password: String? = null var passwordConfirmation: String? = null @@ -16,6 +17,7 @@ class RegisterUserRequest { lastName: String?, email: String?, captchaAnswer: String?, + captchaType: CaptchaType?, password: String?, passwordConfirmation: String? ) { @@ -23,6 +25,7 @@ class RegisterUserRequest { this.lastName = lastName this.email = email this.captchaAnswer = captchaAnswer + this.captchaType = captchaType this.password = password this.passwordConfirmation = passwordConfirmation } diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/WhiteListAdaptor.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/WhiteListAdaptor.kt index 45287565d..c8a5663f2 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/WhiteListAdaptor.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/WhiteListAdaptor.kt @@ -1,7 +1,7 @@ package co.nilin.opex.auth.gateway.data class WhiteListAdaptor { - var data: MutableList?=null + var data: MutableList? = null constructor() constructor(data: MutableList) { diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/ExtendedEventListenerProvider.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/ExtendedEventListenerProvider.kt index 496e4c6c0..248b9944a 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/ExtendedEventListenerProvider.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/ExtendedEventListenerProvider.kt @@ -51,7 +51,7 @@ class ExtendedEventListenerProvider(private val session: KeycloakSession) : Even val realm = model.getRealm(event.realmId) val user = session.users().getUserById(event.userId, realm) if (user != null && user.email != null && user.isEmailVerified) { - logger.info("USER HAS VERIFIED EMAIL : ${event.userId}" ) + logger.info("USER HAS VERIFIED EMAIL : ${event.userId}") // Example of adding an attribute when this event happens user.setSingleAttribute("attribute-key", "attribute-value") diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProvider.java b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProvider.java index 735858fef..443ffdb5c 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProvider.java +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProvider.java @@ -20,6 +20,15 @@ public class HashicorpVaultProvider implements VaultProvider { private String vaultSecretEngineName; private VaultService service; + public HashicorpVaultProvider(String vaultUrl, String vaultAppId, String vaultUserId, String realmName, String vaultSecretEngineName, VaultService service) { + this.vaultUrl = vaultUrl; + this.vaultAppId = vaultAppId; + this.vaultUserId = vaultUserId; + this.realmName = realmName; + this.vaultSecretEngineName = vaultSecretEngineName; + this.service = service; + } + @Override public VaultRawSecret obtainSecret(String vaultSecretId) { int secretVersion = 0; @@ -40,13 +49,4 @@ public VaultRawSecret obtainSecret(String vaultSecretId) { public void close() { } - public HashicorpVaultProvider(String vaultUrl, String vaultAppId, String vaultUserId, String realmName, String vaultSecretEngineName, VaultService service) { - this.vaultUrl = vaultUrl; - this.vaultAppId = vaultAppId; - this.vaultUserId = vaultUserId; - this.realmName = realmName; - this.vaultSecretEngineName = vaultSecretEngineName; - this.service = service; - } - } \ No newline at end of file diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProviderFactory.java b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProviderFactory.java index c546f1646..2a9aad484 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProviderFactory.java +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/HashicorpVaultProviderFactory.java @@ -9,68 +9,66 @@ import org.keycloak.vault.VaultProviderFactory; public class HashicorpVaultProviderFactory implements VaultProviderFactory { - private static final Logger logger = Logger.getLogger(HashicorpVaultProviderFactory.class); + public static final String PROVIDER_ID = "hachicorp-vault"; + private static final Logger logger = Logger.getLogger(HashicorpVaultProviderFactory.class); + private String vaultAppId; + private String vaultUserId; + private String vaultUrl; + private String vaultSecretEngineName; - public static final String PROVIDER_ID = "hachicorp-vault"; + private static String format(String url) { + if (!(url.charAt(url.length() - 1) == '/')) { + return url.concat("/"); + } else { + return url; + } + } - private String vaultAppId; - private String vaultUserId; - private String vaultUrl; - private String vaultSecretEngineName; + @Override + public VaultProvider create(KeycloakSession session) { + VaultService service = new VaultService(session); + if (!service.isVaultAvailable(vaultUrl, vaultAppId, vaultUserId)) { + logger.error("Vault unavailable : " + vaultUrl); + throw new VaultNotFoundException("Vault unavailable : " + vaultUrl); + } else { + logger.info("Vault available : " + vaultUrl); + } + return new HashicorpVaultProvider(vaultUrl, vaultAppId, vaultUserId, session.getContext().getRealm().getName(), vaultSecretEngineName, service); - @Override - public VaultProvider create(KeycloakSession session) { - VaultService service = new VaultService(session); - if (!service.isVaultAvailable(vaultUrl, vaultAppId, vaultUserId)) { - logger.error("Vault unavailable : " + vaultUrl); - throw new VaultNotFoundException("Vault unavailable : " + vaultUrl); - } else { - logger.info("Vault available : " + vaultUrl); - } - return new HashicorpVaultProvider(vaultUrl, vaultAppId, vaultUserId, session.getContext().getRealm().getName(), vaultSecretEngineName, service); + } - } + @Override + public void init(Scope config) { + if (System.getenv("BACKEND_APP") != null) { + vaultAppId = System.getenv("BACKEND_APP"); + } else { + vaultAppId = config.get("appId"); + } + if (System.getenv("BACKEND_USER") != null) { + vaultUserId = System.getenv("BACKEND_USER"); + } else { + vaultUserId = config.get("userId"); + } + vaultUrl = config.get("url") != null ? format(config.get("url")) : null; + vaultSecretEngineName = config.get("engine-name"); + logger.info("Init Hashicorp: " + vaultUrl); + } - private static String format(String url) { - if (!(url.charAt(url.length() - 1) == '/')) { - return url.concat("/"); - } else { - return url; - } - } + @Override + public void postInit(KeycloakSessionFactory factory) { + // TODO Auto-generated method stub - @Override - public void init(Scope config) { - if (System.getenv("BACKEND_APP") != null) { - vaultAppId = System.getenv("BACKEND_APP"); - } else { - vaultAppId = config.get("appId"); - } - if (System.getenv("BACKEND_USER") != null) { - vaultUserId = System.getenv("BACKEND_USER"); - } else { - vaultUserId = config.get("userId"); - } - vaultUrl = config.get("url") != null ? format(config.get("url")) : null; - vaultSecretEngineName = config.get("engine-name"); - logger.info("Init Hashicorp: " + vaultUrl); - } + } - @Override - public void postInit(KeycloakSessionFactory factory) { - // TODO Auto-generated method stub + @Override + public void close() { + // TODO Auto-generated method stub - } + } - @Override - public void close() { - // TODO Auto-generated method stub - - } - - @Override - public String getId() { - return PROVIDER_ID; - } + @Override + public String getId() { + return PROVIDER_ID; + } } diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt index b85d744d6..ba15908ef 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt @@ -32,7 +32,6 @@ import org.keycloak.urls.UrlType import org.keycloak.utils.CredentialHelper import org.keycloak.utils.TotpUtils import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value import org.springframework.kafka.core.KafkaTemplate import java.util.concurrent.TimeUnit import java.util.stream.Collectors @@ -87,7 +86,7 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour if (!auth.hasScopeAccess("trust")) return ErrorHandler.forbidden() runCatching { - validateCaptcha("${request.captchaAnswer}-${session.context.connection.remoteAddr}") + validateCaptcha("${request.captchaAnswer}", request.captchaType ?: CaptchaType.INTERNAL) }.onFailure { return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.InvalidCaptcha) } @@ -140,12 +139,13 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour @Produces(MediaType.APPLICATION_JSON) fun forgotPassword( @QueryParam("email") email: String?, - @QueryParam("captcha") captcha: String + @QueryParam("captcha") captcha: String, + @QueryParam("captchaType") captchaType: CaptchaType?, ): Response { val uri = UriBuilder.fromUri(forgotUrl) runCatching { - validateCaptcha("$captcha-${session.context.connection.remoteAddr}") + validateCaptcha(captcha, captchaType ?: CaptchaType.INTERNAL) }.onFailure { return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.InvalidCaptcha) } @@ -216,9 +216,13 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour @POST @Path("user/request-verify") @Produces(MediaType.APPLICATION_JSON) - fun requestVerifyEmail(@QueryParam("email") email: String?, @QueryParam("captcha") captcha: String): Response { + fun requestVerifyEmail( + @QueryParam("email") email: String?, + @QueryParam("captcha") captcha: String, + @QueryParam("captchaType") captchaType: CaptchaType?, + ): Response { runCatching { - validateCaptcha("${captcha}-${session.context.connection.remoteAddr}") + validateCaptcha(captcha, captchaType ?: CaptchaType.INTERNAL) }.onFailure { return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.InvalidCaptcha) } @@ -430,9 +434,12 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour return session.userCredentialManager().isConfiguredFor(opexRealm, user, OTPCredentialModel.TYPE) } - private fun validateCaptcha(proof: String) { + private fun validateCaptcha(proof: String, type: CaptchaType) { val client: HttpClient = HttpClientBuilder.create().build() - val post = HttpGet(URIBuilder("http://captcha:8080/verify").addParameter("proof", proof).build()) + val post = HttpGet( + URIBuilder("http://captcha:8080/verify").addParameter("proof", proof).addParameter("type", type.name) + .build() + ) client.execute(post).let { response -> logger.info(response.statusLine.statusCode.toString()) check(response.statusLine.statusCode / 500 != 5) { "Could not connect to Opex-Captcha service." } diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/VaultService.java b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/VaultService.java index d6fe61a78..7b0f6e732 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/VaultService.java +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/VaultService.java @@ -15,22 +15,13 @@ */ public class VaultService { - private final KeycloakSession session; private static final Logger logger = Logger.getLogger(VaultService.class); + private final KeycloakSession session; public VaultService(KeycloakSession session) { this.session = session; } - static class UserId { - @JsonProperty("user_id") - public String userId; - - public UserId(String userId) { - this.userId = userId; - } - } - public ByteBuffer getSecretFromVault(String vaultUrl, String realm, String vaultSecretEngineName, String secretName, String vaultAppId, String vaultUserId, int secretVersion) { try { //curl \ --method POST \ --data '{"user_id": ":user_id"}' \ http://127.0.0.1:8200/v1/auth/app-id/login/:app_id @@ -57,4 +48,13 @@ public boolean isVaultAvailable(String vaultUrl, String vaultAppId, String vault } } + static class UserId { + @JsonProperty("user_id") + public String userId; + + public UserId(String userId) { + this.userId = userId; + } + } + } \ No newline at end of file diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/listener/KycLevelUpdatedListener.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/listener/KycLevelUpdatedListener.kt index a4425cef6..4a30719e7 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/listener/KycLevelUpdatedListener.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/listener/KycLevelUpdatedListener.kt @@ -27,8 +27,10 @@ class KycLevelUpdatedListener : KycLevelUpdatedEventListener { return "KycLevelUpdatedListener" } - override fun onEvent(event: KycLevelUpdatedEvent, - partition: Int, offset: Long, timestamp: Long, eventId: String) { + override fun onEvent( + event: KycLevelUpdatedEvent, + partition: Int, offset: Long, timestamp: Long, eventId: String + ) { val factory: KeycloakSessionFactory = KeycloakApplication.getSessionFactory() this.kcSession = factory.create() kcSession!!.transactionManager.begin() diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/UserCreatedEvent.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/UserCreatedEvent.kt index 76a201345..67ea792ee 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/UserCreatedEvent.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/UserCreatedEvent.kt @@ -13,6 +13,7 @@ class UserCreatedEvent : AuthEvent { this.lastName = lastName this.email = email } + constructor() : super() override fun toString(): String { diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/WhiteListModel.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/WhiteListModel.kt index 5d8a25fb9..493eccb70 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/WhiteListModel.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/WhiteListModel.kt @@ -1,7 +1,9 @@ package co.nilin.opex.auth.gateway.model -import javax.persistence.* +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Table @Entity(name = "whitelist") diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/utils/ErrorHandler.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/utils/ErrorHandler.kt index 5c8549ef2..b0281ca45 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/utils/ErrorHandler.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/utils/ErrorHandler.kt @@ -1,20 +1,27 @@ package co.nilin.opex.auth.gateway.utils import co.nilin.opex.common.OpexError -import co.nilin.opex.utility.error.DefaultErrorTranslator +import co.nilin.opex.common.service.CustomErrorTranslator import co.nilin.opex.utility.error.data.OpexException import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response object ErrorHandler { - private val translator = DefaultErrorTranslator() - fun response(status: Response.Status, error: OpexError, message: String? = null): Response { + private val translator = CustomErrorTranslator() + + fun response( + status: Response.Status, + error: OpexError, + message: String? = null + ): Response { + return Response.status(status) - .entity(translator.translate(OpexException(error, message))) + .entity(OpexException(error, message)) .type(MediaType.APPLICATION_JSON_TYPE) .build() + } fun forbidden(message: String? = null) = response(Response.Status.FORBIDDEN, OpexError.Forbidden, message) @@ -25,4 +32,5 @@ object ErrorHandler { fun userNotFound(message: String? = null) = response(Response.Status.NOT_FOUND, OpexError.UserNotFound, message) + } \ No newline at end of file diff --git a/user-management/keycloak-gateway/src/main/resources/META-INF/whitelisttt-changelog.xml b/user-management/keycloak-gateway/src/main/resources/META-INF/whitelisttt-changelog.xml index 7b069bcd5..cc0ede79b 100644 --- a/user-management/keycloak-gateway/src/main/resources/META-INF/whitelisttt-changelog.xml +++ b/user-management/keycloak-gateway/src/main/resources/META-INF/whitelisttt-changelog.xml @@ -1,5 +1,7 @@ - + diff --git a/user-management/keycloak-gateway/src/main/resources/application.yml b/user-management/keycloak-gateway/src/main/resources/application.yml index 07e9bca92..047da958d 100644 --- a/user-management/keycloak-gateway/src/main/resources/application.yml +++ b/user-management/keycloak-gateway/src/main/resources/application.yml @@ -61,7 +61,7 @@ management: web: base-path: /actuator exposure: - include: ["health", "prometheus", "metrics"] + include: [ "health", "prometheus", "metrics" ] endpoint: health: show-details: when_authorized @@ -94,6 +94,9 @@ app: forgot-redirect-url: ${FORGOT_REDIRECT_URL} whitelist: register: - enabled: ${WHITELIST_REGISTER_ENABLED:true} + enabled: ${WHITELIST_REGISTER_ENABLED:true} login: - enabled: ${WHITELIST_LOGIN_ENABLED:true} + enabled: ${WHITELIST_LOGIN_ENABLED:true} + custom-message: + enabled: ${CUSTOM_MESSAGE_ENABLED:false} + base-url: ${CUSTOM_MESSAGE_URL} diff --git a/user-management/keycloak-gateway/src/main/resources/email-templates/execute-action.html b/user-management/keycloak-gateway/src/main/resources/email-templates/execute-action.html index 939b8a962..e964e1e3f 100644 --- a/user-management/keycloak-gateway/src/main/resources/email-templates/execute-action.html +++ b/user-management/keycloak-gateway/src/main/resources/email-templates/execute-action.html @@ -1,16 +1,17 @@ - - + + Email Verification - - + +