From 62e28395b41af99f7c223bbb9830e957be85560b Mon Sep 17 00:00:00 2001 From: meorphis Date: Thu, 14 Mar 2024 16:11:08 -0400 Subject: [PATCH] chore: add back removed code This reverts commit 7796e44d8c6d8cc635710c1998c522c7975ed5eb. --- .../com/lithic/api/client/LithicClient.kt | 2 + .../lithic/api/client/LithicClientAsync.kt | 2 + .../api/client/LithicClientAsyncImpl.kt | 4 + .../com/lithic/api/client/LithicClientImpl.kt | 4 + .../api/models/CardGetEmbedHtmlParams.kt | 366 ++++++++++++++++++ .../api/models/CardGetEmbedUrlParams.kt | 366 ++++++++++++++++++ .../api/services/async/CardServiceAsync.kt | 12 + .../services/async/CardServiceAsyncImpl.kt | 55 +++ .../api/services/async/DisputeServiceAsync.kt | 2 + .../services/async/DisputeServiceAsyncImpl.kt | 23 ++ .../api/services/async/EventServiceAsync.kt | 3 + .../services/async/EventServiceAsyncImpl.kt | 25 ++ .../api/services/async/WebhookServiceAsync.kt | 23 ++ .../services/async/WebhookServiceAsyncImpl.kt | 101 +++++ .../api/services/blocking/CardService.kt | 12 + .../api/services/blocking/CardServiceImpl.kt | 55 +++ .../api/services/blocking/DisputeService.kt | 2 + .../services/blocking/DisputeServiceImpl.kt | 23 ++ .../api/services/blocking/EventService.kt | 3 + .../api/services/blocking/EventServiceImpl.kt | 21 + .../api/services/blocking/WebhookService.kt | 15 + .../services/blocking/WebhookServiceImpl.kt | 101 +++++ .../api/models/CardGetEmbedHtmlParamsTest.kt | 47 +++ .../api/models/CardGetEmbedUrlParamsTest.kt | 47 +++ .../api/services/blocking/CardServiceTest.kt | 30 ++ .../api/services/blocking/EventServiceTest.kt | 13 + .../services/blocking/WebhookServiceTest.kt | 191 +++++++++ 27 files changed, 1548 insertions(+) create mode 100644 lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedHtmlParams.kt create mode 100644 lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedUrlParams.kt create mode 100644 lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsync.kt create mode 100644 lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsyncImpl.kt create mode 100644 lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookService.kt create mode 100644 lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookServiceImpl.kt create mode 100644 lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedHtmlParamsTest.kt create mode 100644 lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedUrlParamsTest.kt create mode 100644 lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/WebhookServiceTest.kt diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClient.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClient.kt index bbb3058f..db085ea9 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClient.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClient.kt @@ -42,6 +42,8 @@ interface LithicClient { fun responderEndpoints(): ResponderEndpointService + fun webhooks(): WebhookService + fun externalBankAccounts(): ExternalBankAccountService fun payments(): PaymentService diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsync.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsync.kt index f5f1e765..80f0d10f 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsync.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsync.kt @@ -42,6 +42,8 @@ interface LithicClientAsync { fun responderEndpoints(): ResponderEndpointServiceAsync + fun webhooks(): WebhookServiceAsync + fun externalBankAccounts(): ExternalBankAccountServiceAsync fun payments(): PaymentServiceAsync diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsyncImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsyncImpl.kt index 6c231c04..601b681b 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsyncImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientAsyncImpl.kt @@ -69,6 +69,8 @@ constructor( ResponderEndpointServiceAsyncImpl(clientOptions) } + private val webhooks: WebhookServiceAsync by lazy { WebhookServiceAsyncImpl(clientOptions) } + private val externalBankAccounts: ExternalBankAccountServiceAsync by lazy { ExternalBankAccountServiceAsyncImpl(clientOptions) } @@ -124,6 +126,8 @@ constructor( override fun responderEndpoints(): ResponderEndpointServiceAsync = responderEndpoints + override fun webhooks(): WebhookServiceAsync = webhooks + override fun externalBankAccounts(): ExternalBankAccountServiceAsync = externalBankAccounts override fun payments(): PaymentServiceAsync = payments diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientImpl.kt index b5a9054a..3111778d 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/client/LithicClientImpl.kt @@ -67,6 +67,8 @@ constructor( ResponderEndpointServiceImpl(clientOptions) } + private val webhooks: WebhookService by lazy { WebhookServiceImpl(clientOptions) } + private val externalBankAccounts: ExternalBankAccountService by lazy { ExternalBankAccountServiceImpl(clientOptions) } @@ -117,6 +119,8 @@ constructor( override fun responderEndpoints(): ResponderEndpointService = responderEndpoints + override fun webhooks(): WebhookService = webhooks + override fun externalBankAccounts(): ExternalBankAccountService = externalBankAccounts override fun payments(): PaymentService = payments diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedHtmlParams.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedHtmlParams.kt new file mode 100644 index 00000000..a40cc168 --- /dev/null +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedHtmlParams.kt @@ -0,0 +1,366 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.lithic.api.models + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.lithic.api.core.ExcludeMissing +import com.lithic.api.core.JsonValue +import com.lithic.api.core.NoAutoDetect +import com.lithic.api.core.toUnmodifiable +import com.lithic.api.models.* +import java.time.OffsetDateTime +import java.util.Objects + +class CardGetEmbedHtmlParams +constructor( + private val token: String, + private val css: String?, + private val expiration: OffsetDateTime?, + private val targetOrigin: String?, + private val additionalQueryParams: Map>, + private val additionalHeaders: Map>, + private val additionalBodyProperties: Map, +) { + + fun token(): String = token + + fun css(): String? = css + + fun expiration(): OffsetDateTime? = expiration + + fun targetOrigin(): String? = targetOrigin + + internal fun getBody(): CardGetEmbedHtmlBody { + return CardGetEmbedHtmlBody( + token, + css, + expiration, + targetOrigin, + additionalBodyProperties, + ) + } + + internal fun getQueryParams(): Map> = additionalQueryParams + + internal fun getHeaders(): Map> = additionalHeaders + + @JsonDeserialize(builder = CardGetEmbedHtmlBody.Builder::class) + @NoAutoDetect + class CardGetEmbedHtmlBody + internal constructor( + private val token: String?, + private val css: String?, + private val expiration: OffsetDateTime?, + private val targetOrigin: String?, + private val additionalProperties: Map, + ) { + + private var hashCode: Int = 0 + + /** Globally unique identifier for the card to be displayed. */ + @JsonProperty("token") fun token(): String? = token + + /** + * A publicly available URI, so the white-labeled card element can be styled with the + * client's branding. + */ + @JsonProperty("css") fun css(): String? = css + + /** + * An RFC 3339 timestamp for when the request should expire. UTC time zone. + * + * If no timezone is specified, UTC will be used. If payload does not contain an expiration, + * the request will never expire. + * + * Using an `expiration` reduces the risk of a + * [replay attack](https://en.wikipedia.org/wiki/Replay_attack). Without supplying the + * `expiration`, in the event that a malicious user gets a copy of your request in transit, + * they will be able to obtain the response data indefinitely. + */ + @JsonProperty("expiration") fun expiration(): OffsetDateTime? = expiration + + /** + * Required if you want to post the element clicked to the parent iframe. + * + * If you supply this param, you can also capture click events in the parent iframe by + * adding an event listener. + */ + @JsonProperty("target_origin") fun targetOrigin(): String? = targetOrigin + + @JsonAnyGetter + @ExcludeMissing + fun _additionalProperties(): Map = additionalProperties + + fun toBuilder() = Builder().from(this) + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + return other is CardGetEmbedHtmlBody && + this.token == other.token && + this.css == other.css && + this.expiration == other.expiration && + this.targetOrigin == other.targetOrigin && + this.additionalProperties == other.additionalProperties + } + + override fun hashCode(): Int { + if (hashCode == 0) { + hashCode = + Objects.hash( + token, + css, + expiration, + targetOrigin, + additionalProperties, + ) + } + return hashCode + } + + override fun toString() = + "CardGetEmbedHtmlBody{token=$token, css=$css, expiration=$expiration, targetOrigin=$targetOrigin, additionalProperties=$additionalProperties}" + + companion object { + + fun builder() = Builder() + } + + class Builder { + + private var token: String? = null + private var css: String? = null + private var expiration: OffsetDateTime? = null + private var targetOrigin: String? = null + private var additionalProperties: MutableMap = mutableMapOf() + + internal fun from(cardGetEmbedHtmlBody: CardGetEmbedHtmlBody) = apply { + this.token = cardGetEmbedHtmlBody.token + this.css = cardGetEmbedHtmlBody.css + this.expiration = cardGetEmbedHtmlBody.expiration + this.targetOrigin = cardGetEmbedHtmlBody.targetOrigin + additionalProperties(cardGetEmbedHtmlBody.additionalProperties) + } + + /** Globally unique identifier for the card to be displayed. */ + @JsonProperty("token") fun token(token: String) = apply { this.token = token } + + /** + * A publicly available URI, so the white-labeled card element can be styled with the + * client's branding. + */ + @JsonProperty("css") fun css(css: String) = apply { this.css = css } + + /** + * An RFC 3339 timestamp for when the request should expire. UTC time zone. + * + * If no timezone is specified, UTC will be used. If payload does not contain an + * expiration, the request will never expire. + * + * Using an `expiration` reduces the risk of a + * [replay attack](https://en.wikipedia.org/wiki/Replay_attack). Without supplying the + * `expiration`, in the event that a malicious user gets a copy of your request in + * transit, they will be able to obtain the response data indefinitely. + */ + @JsonProperty("expiration") + fun expiration(expiration: OffsetDateTime) = apply { this.expiration = expiration } + + /** + * Required if you want to post the element clicked to the parent iframe. + * + * If you supply this param, you can also capture click events in the parent iframe by + * adding an event listener. + */ + @JsonProperty("target_origin") + fun targetOrigin(targetOrigin: String) = apply { this.targetOrigin = targetOrigin } + + fun additionalProperties(additionalProperties: Map) = apply { + this.additionalProperties.clear() + this.additionalProperties.putAll(additionalProperties) + } + + @JsonAnySetter + fun putAdditionalProperty(key: String, value: JsonValue) = apply { + this.additionalProperties.put(key, value) + } + + fun putAllAdditionalProperties(additionalProperties: Map) = apply { + this.additionalProperties.putAll(additionalProperties) + } + + fun build(): CardGetEmbedHtmlBody = + CardGetEmbedHtmlBody( + checkNotNull(token) { "`token` is required but was not set" }, + css, + expiration, + targetOrigin, + additionalProperties.toUnmodifiable(), + ) + } + } + + fun _additionalQueryParams(): Map> = additionalQueryParams + + fun _additionalHeaders(): Map> = additionalHeaders + + fun _additionalBodyProperties(): Map = additionalBodyProperties + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + return other is CardGetEmbedHtmlParams && + this.token == other.token && + this.css == other.css && + this.expiration == other.expiration && + this.targetOrigin == other.targetOrigin && + this.additionalQueryParams == other.additionalQueryParams && + this.additionalHeaders == other.additionalHeaders && + this.additionalBodyProperties == other.additionalBodyProperties + } + + override fun hashCode(): Int { + return Objects.hash( + token, + css, + expiration, + targetOrigin, + additionalQueryParams, + additionalHeaders, + additionalBodyProperties, + ) + } + + override fun toString() = + "CardGetEmbedHtmlParams{token=$token, css=$css, expiration=$expiration, targetOrigin=$targetOrigin, additionalQueryParams=$additionalQueryParams, additionalHeaders=$additionalHeaders, additionalBodyProperties=$additionalBodyProperties}" + + fun toBuilder() = Builder().from(this) + + companion object { + + fun builder() = Builder() + } + + @NoAutoDetect + class Builder { + + private var token: String? = null + private var css: String? = null + private var expiration: OffsetDateTime? = null + private var targetOrigin: String? = null + private var additionalQueryParams: MutableMap> = mutableMapOf() + private var additionalHeaders: MutableMap> = mutableMapOf() + private var additionalBodyProperties: MutableMap = mutableMapOf() + + internal fun from(cardGetEmbedHtmlParams: CardGetEmbedHtmlParams) = apply { + this.token = cardGetEmbedHtmlParams.token + this.css = cardGetEmbedHtmlParams.css + this.expiration = cardGetEmbedHtmlParams.expiration + this.targetOrigin = cardGetEmbedHtmlParams.targetOrigin + additionalQueryParams(cardGetEmbedHtmlParams.additionalQueryParams) + additionalHeaders(cardGetEmbedHtmlParams.additionalHeaders) + additionalBodyProperties(cardGetEmbedHtmlParams.additionalBodyProperties) + } + + /** Globally unique identifier for the card to be displayed. */ + fun token(token: String) = apply { this.token = token } + + /** + * A publicly available URI, so the white-labeled card element can be styled with the + * client's branding. + */ + fun css(css: String) = apply { this.css = css } + + /** + * An RFC 3339 timestamp for when the request should expire. UTC time zone. + * + * If no timezone is specified, UTC will be used. If payload does not contain an expiration, + * the request will never expire. + * + * Using an `expiration` reduces the risk of a + * [replay attack](https://en.wikipedia.org/wiki/Replay_attack). Without supplying the + * `expiration`, in the event that a malicious user gets a copy of your request in transit, + * they will be able to obtain the response data indefinitely. + */ + fun expiration(expiration: OffsetDateTime) = apply { this.expiration = expiration } + + /** + * Required if you want to post the element clicked to the parent iframe. + * + * If you supply this param, you can also capture click events in the parent iframe by + * adding an event listener. + */ + fun targetOrigin(targetOrigin: String) = apply { this.targetOrigin = targetOrigin } + + fun additionalQueryParams(additionalQueryParams: Map>) = apply { + this.additionalQueryParams.clear() + putAllQueryParams(additionalQueryParams) + } + + fun putQueryParam(name: String, value: String) = apply { + this.additionalQueryParams.getOrPut(name) { mutableListOf() }.add(value) + } + + fun putQueryParams(name: String, values: Iterable) = apply { + this.additionalQueryParams.getOrPut(name) { mutableListOf() }.addAll(values) + } + + fun putAllQueryParams(additionalQueryParams: Map>) = apply { + additionalQueryParams.forEach(this::putQueryParams) + } + + fun removeQueryParam(name: String) = apply { + this.additionalQueryParams.put(name, mutableListOf()) + } + + fun additionalHeaders(additionalHeaders: Map>) = apply { + this.additionalHeaders.clear() + putAllHeaders(additionalHeaders) + } + + fun putHeader(name: String, value: String) = apply { + this.additionalHeaders.getOrPut(name) { mutableListOf() }.add(value) + } + + fun putHeaders(name: String, values: Iterable) = apply { + this.additionalHeaders.getOrPut(name) { mutableListOf() }.addAll(values) + } + + fun putAllHeaders(additionalHeaders: Map>) = apply { + additionalHeaders.forEach(this::putHeaders) + } + + fun removeHeader(name: String) = apply { this.additionalHeaders.put(name, mutableListOf()) } + + fun additionalBodyProperties(additionalBodyProperties: Map) = apply { + this.additionalBodyProperties.clear() + this.additionalBodyProperties.putAll(additionalBodyProperties) + } + + fun putAdditionalBodyProperty(key: String, value: JsonValue) = apply { + this.additionalBodyProperties.put(key, value) + } + + fun putAllAdditionalBodyProperties(additionalBodyProperties: Map) = + apply { + this.additionalBodyProperties.putAll(additionalBodyProperties) + } + + fun build(): CardGetEmbedHtmlParams = + CardGetEmbedHtmlParams( + checkNotNull(token) { "`token` is required but was not set" }, + css, + expiration, + targetOrigin, + additionalQueryParams.mapValues { it.value.toUnmodifiable() }.toUnmodifiable(), + additionalHeaders.mapValues { it.value.toUnmodifiable() }.toUnmodifiable(), + additionalBodyProperties.toUnmodifiable(), + ) + } +} diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedUrlParams.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedUrlParams.kt new file mode 100644 index 00000000..e6b9720e --- /dev/null +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/models/CardGetEmbedUrlParams.kt @@ -0,0 +1,366 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.lithic.api.models + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.lithic.api.core.ExcludeMissing +import com.lithic.api.core.JsonValue +import com.lithic.api.core.NoAutoDetect +import com.lithic.api.core.toUnmodifiable +import com.lithic.api.models.* +import java.time.OffsetDateTime +import java.util.Objects + +class CardGetEmbedUrlParams +constructor( + private val token: String, + private val css: String?, + private val expiration: OffsetDateTime?, + private val targetOrigin: String?, + private val additionalQueryParams: Map>, + private val additionalHeaders: Map>, + private val additionalBodyProperties: Map, +) { + + fun token(): String = token + + fun css(): String? = css + + fun expiration(): OffsetDateTime? = expiration + + fun targetOrigin(): String? = targetOrigin + + internal fun getBody(): CardGetEmbedUrlBody { + return CardGetEmbedUrlBody( + token, + css, + expiration, + targetOrigin, + additionalBodyProperties, + ) + } + + internal fun getQueryParams(): Map> = additionalQueryParams + + internal fun getHeaders(): Map> = additionalHeaders + + @JsonDeserialize(builder = CardGetEmbedUrlBody.Builder::class) + @NoAutoDetect + class CardGetEmbedUrlBody + internal constructor( + private val token: String?, + private val css: String?, + private val expiration: OffsetDateTime?, + private val targetOrigin: String?, + private val additionalProperties: Map, + ) { + + private var hashCode: Int = 0 + + /** Globally unique identifier for the card to be displayed. */ + @JsonProperty("token") fun token(): String? = token + + /** + * A publicly available URI, so the white-labeled card element can be styled with the + * client's branding. + */ + @JsonProperty("css") fun css(): String? = css + + /** + * An RFC 3339 timestamp for when the request should expire. UTC time zone. + * + * If no timezone is specified, UTC will be used. If payload does not contain an expiration, + * the request will never expire. + * + * Using an `expiration` reduces the risk of a + * [replay attack](https://en.wikipedia.org/wiki/Replay_attack). Without supplying the + * `expiration`, in the event that a malicious user gets a copy of your request in transit, + * they will be able to obtain the response data indefinitely. + */ + @JsonProperty("expiration") fun expiration(): OffsetDateTime? = expiration + + /** + * Required if you want to post the element clicked to the parent iframe. + * + * If you supply this param, you can also capture click events in the parent iframe by + * adding an event listener. + */ + @JsonProperty("target_origin") fun targetOrigin(): String? = targetOrigin + + @JsonAnyGetter + @ExcludeMissing + fun _additionalProperties(): Map = additionalProperties + + fun toBuilder() = Builder().from(this) + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + return other is CardGetEmbedUrlBody && + this.token == other.token && + this.css == other.css && + this.expiration == other.expiration && + this.targetOrigin == other.targetOrigin && + this.additionalProperties == other.additionalProperties + } + + override fun hashCode(): Int { + if (hashCode == 0) { + hashCode = + Objects.hash( + token, + css, + expiration, + targetOrigin, + additionalProperties, + ) + } + return hashCode + } + + override fun toString() = + "CardGetEmbedUrlBody{token=$token, css=$css, expiration=$expiration, targetOrigin=$targetOrigin, additionalProperties=$additionalProperties}" + + companion object { + + fun builder() = Builder() + } + + class Builder { + + private var token: String? = null + private var css: String? = null + private var expiration: OffsetDateTime? = null + private var targetOrigin: String? = null + private var additionalProperties: MutableMap = mutableMapOf() + + internal fun from(cardGetEmbedUrlBody: CardGetEmbedUrlBody) = apply { + this.token = cardGetEmbedUrlBody.token + this.css = cardGetEmbedUrlBody.css + this.expiration = cardGetEmbedUrlBody.expiration + this.targetOrigin = cardGetEmbedUrlBody.targetOrigin + additionalProperties(cardGetEmbedUrlBody.additionalProperties) + } + + /** Globally unique identifier for the card to be displayed. */ + @JsonProperty("token") fun token(token: String) = apply { this.token = token } + + /** + * A publicly available URI, so the white-labeled card element can be styled with the + * client's branding. + */ + @JsonProperty("css") fun css(css: String) = apply { this.css = css } + + /** + * An RFC 3339 timestamp for when the request should expire. UTC time zone. + * + * If no timezone is specified, UTC will be used. If payload does not contain an + * expiration, the request will never expire. + * + * Using an `expiration` reduces the risk of a + * [replay attack](https://en.wikipedia.org/wiki/Replay_attack). Without supplying the + * `expiration`, in the event that a malicious user gets a copy of your request in + * transit, they will be able to obtain the response data indefinitely. + */ + @JsonProperty("expiration") + fun expiration(expiration: OffsetDateTime) = apply { this.expiration = expiration } + + /** + * Required if you want to post the element clicked to the parent iframe. + * + * If you supply this param, you can also capture click events in the parent iframe by + * adding an event listener. + */ + @JsonProperty("target_origin") + fun targetOrigin(targetOrigin: String) = apply { this.targetOrigin = targetOrigin } + + fun additionalProperties(additionalProperties: Map) = apply { + this.additionalProperties.clear() + this.additionalProperties.putAll(additionalProperties) + } + + @JsonAnySetter + fun putAdditionalProperty(key: String, value: JsonValue) = apply { + this.additionalProperties.put(key, value) + } + + fun putAllAdditionalProperties(additionalProperties: Map) = apply { + this.additionalProperties.putAll(additionalProperties) + } + + fun build(): CardGetEmbedUrlBody = + CardGetEmbedUrlBody( + checkNotNull(token) { "`token` is required but was not set" }, + css, + expiration, + targetOrigin, + additionalProperties.toUnmodifiable(), + ) + } + } + + fun _additionalQueryParams(): Map> = additionalQueryParams + + fun _additionalHeaders(): Map> = additionalHeaders + + fun _additionalBodyProperties(): Map = additionalBodyProperties + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + return other is CardGetEmbedUrlParams && + this.token == other.token && + this.css == other.css && + this.expiration == other.expiration && + this.targetOrigin == other.targetOrigin && + this.additionalQueryParams == other.additionalQueryParams && + this.additionalHeaders == other.additionalHeaders && + this.additionalBodyProperties == other.additionalBodyProperties + } + + override fun hashCode(): Int { + return Objects.hash( + token, + css, + expiration, + targetOrigin, + additionalQueryParams, + additionalHeaders, + additionalBodyProperties, + ) + } + + override fun toString() = + "CardGetEmbedUrlParams{token=$token, css=$css, expiration=$expiration, targetOrigin=$targetOrigin, additionalQueryParams=$additionalQueryParams, additionalHeaders=$additionalHeaders, additionalBodyProperties=$additionalBodyProperties}" + + fun toBuilder() = Builder().from(this) + + companion object { + + fun builder() = Builder() + } + + @NoAutoDetect + class Builder { + + private var token: String? = null + private var css: String? = null + private var expiration: OffsetDateTime? = null + private var targetOrigin: String? = null + private var additionalQueryParams: MutableMap> = mutableMapOf() + private var additionalHeaders: MutableMap> = mutableMapOf() + private var additionalBodyProperties: MutableMap = mutableMapOf() + + internal fun from(cardGetEmbedUrlParams: CardGetEmbedUrlParams) = apply { + this.token = cardGetEmbedUrlParams.token + this.css = cardGetEmbedUrlParams.css + this.expiration = cardGetEmbedUrlParams.expiration + this.targetOrigin = cardGetEmbedUrlParams.targetOrigin + additionalQueryParams(cardGetEmbedUrlParams.additionalQueryParams) + additionalHeaders(cardGetEmbedUrlParams.additionalHeaders) + additionalBodyProperties(cardGetEmbedUrlParams.additionalBodyProperties) + } + + /** Globally unique identifier for the card to be displayed. */ + fun token(token: String) = apply { this.token = token } + + /** + * A publicly available URI, so the white-labeled card element can be styled with the + * client's branding. + */ + fun css(css: String) = apply { this.css = css } + + /** + * An RFC 3339 timestamp for when the request should expire. UTC time zone. + * + * If no timezone is specified, UTC will be used. If payload does not contain an expiration, + * the request will never expire. + * + * Using an `expiration` reduces the risk of a + * [replay attack](https://en.wikipedia.org/wiki/Replay_attack). Without supplying the + * `expiration`, in the event that a malicious user gets a copy of your request in transit, + * they will be able to obtain the response data indefinitely. + */ + fun expiration(expiration: OffsetDateTime) = apply { this.expiration = expiration } + + /** + * Required if you want to post the element clicked to the parent iframe. + * + * If you supply this param, you can also capture click events in the parent iframe by + * adding an event listener. + */ + fun targetOrigin(targetOrigin: String) = apply { this.targetOrigin = targetOrigin } + + fun additionalQueryParams(additionalQueryParams: Map>) = apply { + this.additionalQueryParams.clear() + putAllQueryParams(additionalQueryParams) + } + + fun putQueryParam(name: String, value: String) = apply { + this.additionalQueryParams.getOrPut(name) { mutableListOf() }.add(value) + } + + fun putQueryParams(name: String, values: Iterable) = apply { + this.additionalQueryParams.getOrPut(name) { mutableListOf() }.addAll(values) + } + + fun putAllQueryParams(additionalQueryParams: Map>) = apply { + additionalQueryParams.forEach(this::putQueryParams) + } + + fun removeQueryParam(name: String) = apply { + this.additionalQueryParams.put(name, mutableListOf()) + } + + fun additionalHeaders(additionalHeaders: Map>) = apply { + this.additionalHeaders.clear() + putAllHeaders(additionalHeaders) + } + + fun putHeader(name: String, value: String) = apply { + this.additionalHeaders.getOrPut(name) { mutableListOf() }.add(value) + } + + fun putHeaders(name: String, values: Iterable) = apply { + this.additionalHeaders.getOrPut(name) { mutableListOf() }.addAll(values) + } + + fun putAllHeaders(additionalHeaders: Map>) = apply { + additionalHeaders.forEach(this::putHeaders) + } + + fun removeHeader(name: String) = apply { this.additionalHeaders.put(name, mutableListOf()) } + + fun additionalBodyProperties(additionalBodyProperties: Map) = apply { + this.additionalBodyProperties.clear() + this.additionalBodyProperties.putAll(additionalBodyProperties) + } + + fun putAdditionalBodyProperty(key: String, value: JsonValue) = apply { + this.additionalBodyProperties.put(key, value) + } + + fun putAllAdditionalBodyProperties(additionalBodyProperties: Map) = + apply { + this.additionalBodyProperties.putAll(additionalBodyProperties) + } + + fun build(): CardGetEmbedUrlParams = + CardGetEmbedUrlParams( + checkNotNull(token) { "`token` is required but was not set" }, + css, + expiration, + targetOrigin, + additionalQueryParams.mapValues { it.value.toUnmodifiable() }.toUnmodifiable(), + additionalHeaders.mapValues { it.value.toUnmodifiable() }.toUnmodifiable(), + additionalBodyProperties.toUnmodifiable(), + ) + } +} diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsync.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsync.kt index 09b8834d..75781e6b 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsync.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsync.kt @@ -8,6 +8,8 @@ import com.lithic.api.core.RequestOptions import com.lithic.api.models.Card import com.lithic.api.models.CardCreateParams import com.lithic.api.models.CardEmbedParams +import com.lithic.api.models.CardGetEmbedHtmlParams +import com.lithic.api.models.CardGetEmbedUrlParams import com.lithic.api.models.CardListPageAsync import com.lithic.api.models.CardListParams import com.lithic.api.models.CardProvisionParams @@ -147,4 +149,14 @@ interface CardServiceAsync { params: CardSearchByPanParams, requestOptions: RequestOptions = RequestOptions.none() ): Card + + suspend fun getEmbedHtml( + params: CardGetEmbedHtmlParams, + requestOptions: RequestOptions = RequestOptions.none() + ): String + + suspend fun getEmbedUrl( + params: CardGetEmbedUrlParams, + requestOptions: RequestOptions = RequestOptions.none() + ): String } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsyncImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsyncImpl.kt index 6042a023..97b18000 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsyncImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/CardServiceAsyncImpl.kt @@ -11,6 +11,8 @@ import com.lithic.api.errors.LithicError import com.lithic.api.models.Card import com.lithic.api.models.CardCreateParams import com.lithic.api.models.CardEmbedParams +import com.lithic.api.models.CardGetEmbedHtmlParams +import com.lithic.api.models.CardGetEmbedUrlParams import com.lithic.api.models.CardListPageAsync import com.lithic.api.models.CardListParams import com.lithic.api.models.CardProvisionParams @@ -33,6 +35,11 @@ import com.lithic.api.services.json import com.lithic.api.services.jsonHandler import com.lithic.api.services.stringHandler import com.lithic.api.services.withErrorHandler +import java.net.URI +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import org.apache.hc.core5.net.URIBuilder class CardServiceAsyncImpl constructor( @@ -369,4 +376,52 @@ constructor( } } } + + override suspend fun getEmbedHtml( + params: CardGetEmbedHtmlParams, + requestOptions: RequestOptions + ): String { + val embed_request = + Base64.getEncoder() + .encodeToString(clientOptions.jsonMapper.writeValueAsBytes(params.getBody())) + + val mac: Mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(clientOptions.apiKey.toByteArray(), "HmacSHA256")) + val embed_request_hmac = + Base64.getEncoder().encodeToString(mac.doFinal(embed_request.toByteArray())) + + val request = + HttpRequest.builder() + .method(HttpMethod.GET) + .addPathSegments("embed", "card") + .putQueryParam("embed_request", embed_request) + .putQueryParam("hmac", embed_request_hmac) + .putAllHeaders(clientOptions.headers) + .putAllHeaders(params.getHeaders()) + .putHeader("Accept", "text/html") + .build() + return clientOptions.httpClient.executeAsync(request).let { response -> + response.let { embedHandler.handle(it) } + } + } + + override suspend fun getEmbedUrl( + params: CardGetEmbedUrlParams, + requestOptions: RequestOptions + ): String { + val embed_request = + Base64.getEncoder() + .encodeToString(clientOptions.jsonMapper.writeValueAsBytes(params.getBody())) + + val mac: Mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(clientOptions.apiKey.toByteArray(), "HmacSHA256")) + val embed_request_hmac = + Base64.getEncoder().encodeToString(mac.doFinal(embed_request.toByteArray())) + + return URIBuilder(URI.create(clientOptions.baseUrl)) + .appendPathSegments("embed", "card") + .addParameter("embed_request", embed_request) + .addParameter("hmac", embed_request_hmac) + .toString() + } } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsync.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsync.kt index 4580d724..bb5f89ec 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsync.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsync.kt @@ -83,4 +83,6 @@ interface DisputeServiceAsync { params: DisputeRetrieveEvidenceParams, requestOptions: RequestOptions = RequestOptions.none() ): DisputeEvidence + + suspend fun uploadEvidence(disputeToken: String, file: ByteArray) } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsyncImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsyncImpl.kt index a688e0c6..7e0c5cf3 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsyncImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/DisputeServiceAsyncImpl.kt @@ -8,6 +8,7 @@ import com.lithic.api.core.http.HttpMethod import com.lithic.api.core.http.HttpRequest import com.lithic.api.core.http.HttpResponse.Handler import com.lithic.api.errors.LithicError +import com.lithic.api.errors.LithicInvalidDataException import com.lithic.api.models.Dispute import com.lithic.api.models.DisputeCreateParams import com.lithic.api.models.DisputeDeleteEvidenceParams @@ -21,9 +22,11 @@ import com.lithic.api.models.DisputeListParams import com.lithic.api.models.DisputeRetrieveEvidenceParams import com.lithic.api.models.DisputeRetrieveParams import com.lithic.api.models.DisputeUpdateParams +import com.lithic.api.services.emptyHandler import com.lithic.api.services.errorHandler import com.lithic.api.services.json import com.lithic.api.services.jsonHandler +import com.lithic.api.services.multipartFormData import com.lithic.api.services.withErrorHandler class DisputeServiceAsyncImpl @@ -303,4 +306,24 @@ constructor( } } } + + override suspend fun uploadEvidence(disputeToken: String, file: ByteArray) { + val initiateParams = + DisputeInitiateEvidenceUploadParams.builder().disputeToken(disputeToken).build() + val initiateResponse = initiateEvidenceUpload(initiateParams) + + val uploadUrl = + initiateResponse.uploadUrl() + ?: throw LithicInvalidDataException("Missing 'upload_url' from response payload") + + val uploadRequest = + HttpRequest.builder() + .method(HttpMethod.PUT) + .url(uploadUrl) + .body(multipartFormData(mapOf("file" to file))) + .build() + clientOptions.httpClient.executeAsync(uploadRequest).let { response -> + response.let { emptyHandler().handle(it) } + } + } } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsync.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsync.kt index de562443..0c9ec254 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsync.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsync.kt @@ -4,6 +4,7 @@ package com.lithic.api.services.async +import com.lithic.api.core.JsonValue import com.lithic.api.core.RequestOptions import com.lithic.api.models.Event import com.lithic.api.models.EventListAttemptsPageAsync @@ -34,4 +35,6 @@ interface EventServiceAsync { params: EventListAttemptsParams, requestOptions: RequestOptions = RequestOptions.none() ): EventListAttemptsPageAsync + + suspend fun resend(eventToken: String, eventSubscriptionToken: String, body: JsonValue) } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsyncImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsyncImpl.kt index 01a19b78..da4d04fa 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsyncImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/EventServiceAsyncImpl.kt @@ -3,6 +3,7 @@ package com.lithic.api.services.async import com.lithic.api.core.ClientOptions +import com.lithic.api.core.JsonValue import com.lithic.api.core.RequestOptions import com.lithic.api.core.http.HttpMethod import com.lithic.api.core.http.HttpRequest @@ -16,7 +17,9 @@ import com.lithic.api.models.EventListParams import com.lithic.api.models.EventRetrieveParams import com.lithic.api.services.async.events.SubscriptionServiceAsync import com.lithic.api.services.async.events.SubscriptionServiceAsyncImpl +import com.lithic.api.services.emptyHandler import com.lithic.api.services.errorHandler +import com.lithic.api.services.json import com.lithic.api.services.jsonHandler import com.lithic.api.services.withErrorHandler @@ -117,4 +120,26 @@ constructor( .let { EventListAttemptsPageAsync.of(this, params, it) } } } + + override suspend fun resend( + eventToken: String, + eventSubscriptionToken: String, + body: JsonValue + ) { + val request = + HttpRequest.builder() + .method(HttpMethod.POST) + .addPathSegments( + "events", + eventToken, + "event_subscriptions", + eventSubscriptionToken, + "resend" + ) + .body(json(clientOptions.jsonMapper, body)) + .build() + clientOptions.httpClient.executeAsync(request).let { response -> + response.let { emptyHandler().handle(it) } + } + } } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsync.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsync.kt new file mode 100644 index 00000000..01bbf20d --- /dev/null +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsync.kt @@ -0,0 +1,23 @@ +// File generated from our OpenAPI spec by Stainless. + +@file:Suppress("OVERLOADS_INTERFACE") // See https://youtrack.jetbrains.com/issue/KT-36102 + +package com.lithic.api.services.async + +import com.google.common.collect.ListMultimap +import com.lithic.api.core.JsonValue + +interface WebhookServiceAsync { + + suspend fun unwrap( + payload: String, + headers: ListMultimap, + secret: String? + ): JsonValue + + suspend fun verifySignature( + payload: String, + headers: ListMultimap, + secret: String? + ) +} diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsyncImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsyncImpl.kt new file mode 100644 index 00000000..0d10b7c9 --- /dev/null +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsyncImpl.kt @@ -0,0 +1,101 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.lithic.api.services.async + +import com.fasterxml.jackson.core.JsonProcessingException +import com.google.common.collect.ListMultimap +import com.lithic.api.core.ClientOptions +import com.lithic.api.core.JsonValue +import com.lithic.api.core.getRequiredHeader +import com.lithic.api.core.http.HttpResponse.Handler +import com.lithic.api.errors.LithicError +import com.lithic.api.errors.LithicException +import com.lithic.api.services.errorHandler +import java.security.MessageDigest +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +class WebhookServiceAsyncImpl +constructor( + private val clientOptions: ClientOptions, +) : WebhookServiceAsync { + + private val errorHandler: Handler = errorHandler(clientOptions.jsonMapper) + + override suspend fun unwrap( + payload: String, + headers: ListMultimap, + secret: String? + ): JsonValue { + verifySignature(payload, headers, secret) + return try { + clientOptions.jsonMapper.readValue(payload, JsonValue::class.java) + } catch (e: JsonProcessingException) { + throw LithicException("Invalid event payload", e) + } + } + + override suspend fun verifySignature( + payload: String, + headers: ListMultimap, + secret: String? + ) { + val webhookSecret = + secret + ?: clientOptions.webhookSecret + ?: throw LithicException( + "The webhook secret must either be set using the env var, LITHIC_WEBHOOK_SECRET, on the client class, or passed to this method" + ) + + val whsecret = + try { + Base64.getDecoder().decode(webhookSecret.removePrefix("whsec_")) + } catch (e: RuntimeException) { + throw LithicException("Invalid webhook secret") + } + + val msgId = headers.getRequiredHeader("webhook-id") + val msgSignature = headers.getRequiredHeader("webhook-signature") + val msgTimestamp = headers.getRequiredHeader("webhook-timestamp") + + val timestamp = + try { + Instant.ofEpochSecond(msgTimestamp.toLong()) + } catch (e: RuntimeException) { + throw LithicException("Invalid signature headers", e) + } + val now = Instant.now(clientOptions.clock) + + if (timestamp.isBefore(now.minus(Duration.ofMinutes(5)))) { + throw LithicException("Webhook timestamp too old") + } + if (timestamp.isAfter(now.plus(Duration.ofMinutes(5)))) { + throw LithicException("Webhook timestamp too new") + } + + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(whsecret, "HmacSHA256")) + val expectedSignature = + mac.doFinal("$msgId.${timestamp.epochSecond}.$payload".toByteArray()) + + msgSignature.splitToSequence(" ").forEach { + val parts = it.split(",") + if (parts.size != 2) { + return@forEach + } + + if (parts[0] != "v1") { + return@forEach + } + + if (MessageDigest.isEqual(Base64.getDecoder().decode(parts[1]), expectedSignature)) { + return + } + } + + throw LithicException("None of the given webhook signatures match the expected signature") + } +} diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardService.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardService.kt index 3ebb7af3..b6de0318 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardService.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardService.kt @@ -8,6 +8,8 @@ import com.lithic.api.core.RequestOptions import com.lithic.api.models.Card import com.lithic.api.models.CardCreateParams import com.lithic.api.models.CardEmbedParams +import com.lithic.api.models.CardGetEmbedHtmlParams +import com.lithic.api.models.CardGetEmbedUrlParams import com.lithic.api.models.CardListPage import com.lithic.api.models.CardListParams import com.lithic.api.models.CardProvisionParams @@ -144,4 +146,14 @@ interface CardService { params: CardSearchByPanParams, requestOptions: RequestOptions = RequestOptions.none() ): Card + + fun getEmbedHtml( + params: CardGetEmbedHtmlParams, + requestOptions: RequestOptions = RequestOptions.none() + ): String + + fun getEmbedUrl( + params: CardGetEmbedUrlParams, + requestOptions: RequestOptions = RequestOptions.none() + ): String } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardServiceImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardServiceImpl.kt index 01be0276..90e8fb97 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardServiceImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/CardServiceImpl.kt @@ -11,6 +11,8 @@ import com.lithic.api.errors.LithicError import com.lithic.api.models.Card import com.lithic.api.models.CardCreateParams import com.lithic.api.models.CardEmbedParams +import com.lithic.api.models.CardGetEmbedHtmlParams +import com.lithic.api.models.CardGetEmbedUrlParams import com.lithic.api.models.CardListPage import com.lithic.api.models.CardListParams import com.lithic.api.models.CardProvisionParams @@ -33,6 +35,11 @@ import com.lithic.api.services.json import com.lithic.api.services.jsonHandler import com.lithic.api.services.stringHandler import com.lithic.api.services.withErrorHandler +import java.net.URI +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import org.apache.hc.core5.net.URIBuilder class CardServiceImpl constructor( @@ -359,4 +366,52 @@ constructor( } } } + + override fun getEmbedHtml( + params: CardGetEmbedHtmlParams, + requestOptions: RequestOptions + ): String { + val embed_request = + Base64.getEncoder() + .encodeToString(clientOptions.jsonMapper.writeValueAsBytes(params.getBody())) + + val mac: Mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(clientOptions.apiKey.toByteArray(), "HmacSHA256")) + val embed_request_hmac = + Base64.getEncoder().encodeToString(mac.doFinal(embed_request.toByteArray())) + + val request = + HttpRequest.builder() + .method(HttpMethod.GET) + .addPathSegments("embed", "card") + .putQueryParam("embed_request", embed_request) + .putQueryParam("hmac", embed_request_hmac) + .putAllHeaders(clientOptions.headers) + .putAllHeaders(params.getHeaders()) + .putHeader("Accept", "text/html") + .build() + return clientOptions.httpClient.execute(request).let { response -> + response.let { embedHandler.handle(it) } + } + } + + override fun getEmbedUrl( + params: CardGetEmbedUrlParams, + requestOptions: RequestOptions + ): String { + val embed_request = + Base64.getEncoder() + .encodeToString(clientOptions.jsonMapper.writeValueAsBytes(params.getBody())) + + val mac: Mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(clientOptions.apiKey.toByteArray(), "HmacSHA256")) + val embed_request_hmac = + Base64.getEncoder().encodeToString(mac.doFinal(embed_request.toByteArray())) + + return URIBuilder(URI.create(clientOptions.baseUrl)) + .appendPathSegments("embed", "card") + .addParameter("embed_request", embed_request) + .addParameter("hmac", embed_request_hmac) + .toString() + } } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeService.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeService.kt index a03447f1..0b947b78 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeService.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeService.kt @@ -83,4 +83,6 @@ interface DisputeService { params: DisputeRetrieveEvidenceParams, requestOptions: RequestOptions = RequestOptions.none() ): DisputeEvidence + + fun uploadEvidence(disputeToken: String, file: ByteArray) } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeServiceImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeServiceImpl.kt index f7bc8aaf..69aa16de 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeServiceImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/DisputeServiceImpl.kt @@ -8,6 +8,7 @@ import com.lithic.api.core.http.HttpMethod import com.lithic.api.core.http.HttpRequest import com.lithic.api.core.http.HttpResponse.Handler import com.lithic.api.errors.LithicError +import com.lithic.api.errors.LithicInvalidDataException import com.lithic.api.models.Dispute import com.lithic.api.models.DisputeCreateParams import com.lithic.api.models.DisputeDeleteEvidenceParams @@ -21,9 +22,11 @@ import com.lithic.api.models.DisputeListParams import com.lithic.api.models.DisputeRetrieveEvidenceParams import com.lithic.api.models.DisputeRetrieveParams import com.lithic.api.models.DisputeUpdateParams +import com.lithic.api.services.emptyHandler import com.lithic.api.services.errorHandler import com.lithic.api.services.json import com.lithic.api.services.jsonHandler +import com.lithic.api.services.multipartFormData import com.lithic.api.services.withErrorHandler class DisputeServiceImpl @@ -288,4 +291,24 @@ constructor( } } } + + override fun uploadEvidence(disputeToken: String, file: ByteArray) { + val initiateParams = + DisputeInitiateEvidenceUploadParams.builder().disputeToken(disputeToken).build() + val initiateResponse = initiateEvidenceUpload(initiateParams) + + val uploadUrl = + initiateResponse.uploadUrl() + ?: throw LithicInvalidDataException("Missing 'upload_url' from response payload") + + val uploadRequest = + HttpRequest.builder() + .method(HttpMethod.PUT) + .url(uploadUrl) + .body(multipartFormData(mapOf("file" to file))) + .build() + clientOptions.httpClient.execute(uploadRequest).let { response -> + response.let { emptyHandler().handle(it) } + } + } } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventService.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventService.kt index d74e3628..4c05cadf 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventService.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventService.kt @@ -4,6 +4,7 @@ package com.lithic.api.services.blocking +import com.lithic.api.core.JsonValue import com.lithic.api.core.RequestOptions import com.lithic.api.models.Event import com.lithic.api.models.EventListAttemptsPage @@ -34,4 +35,6 @@ interface EventService { params: EventListAttemptsParams, requestOptions: RequestOptions = RequestOptions.none() ): EventListAttemptsPage + + fun resend(eventToken: String, eventSubscriptionToken: String, body: JsonValue) } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventServiceImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventServiceImpl.kt index ce5709b7..d4136065 100644 --- a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventServiceImpl.kt +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/EventServiceImpl.kt @@ -3,6 +3,7 @@ package com.lithic.api.services.blocking import com.lithic.api.core.ClientOptions +import com.lithic.api.core.JsonValue import com.lithic.api.core.RequestOptions import com.lithic.api.core.http.HttpMethod import com.lithic.api.core.http.HttpRequest @@ -16,7 +17,9 @@ import com.lithic.api.models.EventListParams import com.lithic.api.models.EventRetrieveParams import com.lithic.api.services.blocking.events.SubscriptionService import com.lithic.api.services.blocking.events.SubscriptionServiceImpl +import com.lithic.api.services.emptyHandler import com.lithic.api.services.errorHandler +import com.lithic.api.services.json import com.lithic.api.services.jsonHandler import com.lithic.api.services.withErrorHandler @@ -110,4 +113,22 @@ constructor( .let { EventListAttemptsPage.of(this, params, it) } } } + + override fun resend(eventToken: String, eventSubscriptionToken: String, body: JsonValue) { + val request = + HttpRequest.builder() + .method(HttpMethod.POST) + .addPathSegments( + "events", + eventToken, + "event_subscriptions", + eventSubscriptionToken, + "resend" + ) + .body(json(clientOptions.jsonMapper, body)) + .build() + clientOptions.httpClient.execute(request).let { response -> + response.let { emptyHandler().handle(it) } + } + } } diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookService.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookService.kt new file mode 100644 index 00000000..fc9d0db1 --- /dev/null +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookService.kt @@ -0,0 +1,15 @@ +// File generated from our OpenAPI spec by Stainless. + +@file:Suppress("OVERLOADS_INTERFACE") // See https://youtrack.jetbrains.com/issue/KT-36102 + +package com.lithic.api.services.blocking + +import com.google.common.collect.ListMultimap +import com.lithic.api.core.JsonValue + +interface WebhookService { + + fun unwrap(payload: String, headers: ListMultimap, secret: String?): JsonValue + + fun verifySignature(payload: String, headers: ListMultimap, secret: String?) +} diff --git a/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookServiceImpl.kt b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookServiceImpl.kt new file mode 100644 index 00000000..3f71c929 --- /dev/null +++ b/lithic-kotlin-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookServiceImpl.kt @@ -0,0 +1,101 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.lithic.api.services.blocking + +import com.fasterxml.jackson.core.JsonProcessingException +import com.google.common.collect.ListMultimap +import com.lithic.api.core.ClientOptions +import com.lithic.api.core.JsonValue +import com.lithic.api.core.getRequiredHeader +import com.lithic.api.core.http.HttpResponse.Handler +import com.lithic.api.errors.LithicError +import com.lithic.api.errors.LithicException +import com.lithic.api.services.errorHandler +import java.security.MessageDigest +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +class WebhookServiceImpl +constructor( + private val clientOptions: ClientOptions, +) : WebhookService { + + private val errorHandler: Handler = errorHandler(clientOptions.jsonMapper) + + override fun unwrap( + payload: String, + headers: ListMultimap, + secret: String? + ): JsonValue { + verifySignature(payload, headers, secret) + return try { + clientOptions.jsonMapper.readValue(payload, JsonValue::class.java) + } catch (e: JsonProcessingException) { + throw LithicException("Invalid event payload", e) + } + } + + override fun verifySignature( + payload: String, + headers: ListMultimap, + secret: String? + ) { + val webhookSecret = + secret + ?: clientOptions.webhookSecret + ?: throw LithicException( + "The webhook secret must either be set using the env var, LITHIC_WEBHOOK_SECRET, on the client class, or passed to this method" + ) + + val whsecret = + try { + Base64.getDecoder().decode(webhookSecret.removePrefix("whsec_")) + } catch (e: RuntimeException) { + throw LithicException("Invalid webhook secret") + } + + val msgId = headers.getRequiredHeader("webhook-id") + val msgSignature = headers.getRequiredHeader("webhook-signature") + val msgTimestamp = headers.getRequiredHeader("webhook-timestamp") + + val timestamp = + try { + Instant.ofEpochSecond(msgTimestamp.toLong()) + } catch (e: RuntimeException) { + throw LithicException("Invalid signature headers", e) + } + val now = Instant.now(clientOptions.clock) + + if (timestamp.isBefore(now.minus(Duration.ofMinutes(5)))) { + throw LithicException("Webhook timestamp too old") + } + if (timestamp.isAfter(now.plus(Duration.ofMinutes(5)))) { + throw LithicException("Webhook timestamp too new") + } + + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(whsecret, "HmacSHA256")) + val expectedSignature = + mac.doFinal("$msgId.${timestamp.epochSecond}.$payload".toByteArray()) + + msgSignature.splitToSequence(" ").forEach { + val parts = it.split(",") + if (parts.size != 2) { + return@forEach + } + + if (parts[0] != "v1") { + return@forEach + } + + if (MessageDigest.isEqual(Base64.getDecoder().decode(parts[1]), expectedSignature)) { + return + } + } + + throw LithicException("None of the given webhook signatures match the expected signature") + } +} diff --git a/lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedHtmlParamsTest.kt b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedHtmlParamsTest.kt new file mode 100644 index 00000000..d9971cd9 --- /dev/null +++ b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedHtmlParamsTest.kt @@ -0,0 +1,47 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.lithic.api.models + +import com.lithic.api.models.* +import java.time.OffsetDateTime +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class CardGetEmbedHtmlParamsTest { + + @Test + fun createCardGetEmbedHtmlParams() { + CardGetEmbedHtmlParams.builder() + .token("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + .css("string") + .expiration(OffsetDateTime.parse("2019-12-27T18:11:19.117Z")) + .targetOrigin("string") + .build() + } + + @Test + fun getBody() { + val params = + CardGetEmbedHtmlParams.builder() + .token("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + .css("string") + .expiration(OffsetDateTime.parse("2019-12-27T18:11:19.117Z")) + .targetOrigin("string") + .build() + val body = params.getBody() + assertThat(body).isNotNull + assertThat(body.token()).isEqualTo("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + assertThat(body.css()).isEqualTo("string") + assertThat(body.expiration()).isEqualTo(OffsetDateTime.parse("2019-12-27T18:11:19.117Z")) + assertThat(body.targetOrigin()).isEqualTo("string") + } + + @Test + fun getBodyWithoutOptionalFields() { + val params = + CardGetEmbedHtmlParams.builder().token("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e").build() + val body = params.getBody() + assertThat(body).isNotNull + assertThat(body.token()).isEqualTo("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + } +} diff --git a/lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedUrlParamsTest.kt b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedUrlParamsTest.kt new file mode 100644 index 00000000..fe70f790 --- /dev/null +++ b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/models/CardGetEmbedUrlParamsTest.kt @@ -0,0 +1,47 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.lithic.api.models + +import com.lithic.api.models.* +import java.time.OffsetDateTime +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class CardGetEmbedUrlParamsTest { + + @Test + fun createCardGetEmbedUrlParams() { + CardGetEmbedUrlParams.builder() + .token("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + .css("string") + .expiration(OffsetDateTime.parse("2019-12-27T18:11:19.117Z")) + .targetOrigin("string") + .build() + } + + @Test + fun getBody() { + val params = + CardGetEmbedUrlParams.builder() + .token("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + .css("string") + .expiration(OffsetDateTime.parse("2019-12-27T18:11:19.117Z")) + .targetOrigin("string") + .build() + val body = params.getBody() + assertThat(body).isNotNull + assertThat(body.token()).isEqualTo("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + assertThat(body.css()).isEqualTo("string") + assertThat(body.expiration()).isEqualTo(OffsetDateTime.parse("2019-12-27T18:11:19.117Z")) + assertThat(body.targetOrigin()).isEqualTo("string") + } + + @Test + fun getBodyWithoutOptionalFields() { + val params = + CardGetEmbedUrlParams.builder().token("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e").build() + val body = params.getBody() + assertThat(body).isNotNull + assertThat(body.token()).isEqualTo("182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e") + } +} diff --git a/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/CardServiceTest.kt b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/CardServiceTest.kt index ef598a2d..5514cee2 100644 --- a/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/CardServiceTest.kt +++ b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/CardServiceTest.kt @@ -259,4 +259,34 @@ class CardServiceTest { println(card) card.validate() } + + @Test + fun callGetEmbedHtml() { + val client = + LithicOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("test-api-key") + .webhookSecret("string") + .build() + val cardService = client.cards() + val cardEmbedResponse = + cardService.getEmbedHtml(CardGetEmbedHtmlParams.builder().token("foo").build()) + println(cardEmbedResponse) + assertThat(cardEmbedResponse).contains("") + } + + @Test + fun callGetEmbedUrl() { + val client = + LithicOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("test-api-key") + .webhookSecret("string") + .build() + val cardService = client.cards() + val cardEmbedUrl = + cardService.getEmbedUrl(CardGetEmbedUrlParams.builder().token("foo").build()) + println(cardEmbedUrl) + assertThat(cardEmbedUrl).contains("hmac") + } } diff --git a/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/EventServiceTest.kt b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/EventServiceTest.kt index 78dec67b..d81cecf0 100644 --- a/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/EventServiceTest.kt +++ b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/EventServiceTest.kt @@ -4,6 +4,7 @@ package com.lithic.api.services.blocking import com.lithic.api.TestServerExtension import com.lithic.api.client.okhttp.LithicOkHttpClient +import com.lithic.api.core.JsonString import com.lithic.api.models.* import com.lithic.api.models.EventListAttemptsParams import com.lithic.api.models.EventListParams @@ -55,4 +56,16 @@ class EventServiceTest { println(response) response.data().forEach { it.validate() } } + + @Test + fun callResend() { + val client = + LithicOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("test-api-key") + .webhookSecret("string") + .build() + val eventService = client.events() + eventService.resend("eventToken", "eventSubscriptionToken", JsonString.of("body")) + } } diff --git a/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/WebhookServiceTest.kt b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/WebhookServiceTest.kt new file mode 100644 index 00000000..bbf80d57 --- /dev/null +++ b/lithic-kotlin-core/src/test/kotlin/com/lithic/api/services/blocking/WebhookServiceTest.kt @@ -0,0 +1,191 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.lithic.api.services.blocking + +import com.google.common.collect.ImmutableListMultimap +import com.lithic.api.TestServerExtension +import com.lithic.api.client.okhttp.LithicOkHttpClient +import com.lithic.api.errors.LithicException +import com.lithic.api.models.* +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import org.assertj.core.api.Assertions.* +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestServerExtension::class) +class WebhookServiceTest { + + @Test + fun unwrap() { + val client = + LithicOkHttpClient.builder() + .apiKey("test-api-key") + .webhookSecret("whsec_zlFsbBZ8Xcodlpcu6NDTdSzZRLSdhkst") + .clock(Clock.fixed(Instant.ofEpochSecond(1676312382), ZoneOffset.UTC)) + .build() + + val payload = + "{\"card_token\":\"sit Lorem ipsum, accusantium repellendus possimus\",\"created_at\":\"elit. placeat libero architecto molestias, sit\",\"account_token\":\"elit.\",\"issuer_decision\":\"magnam, libero esse Lorem ipsum magnam, magnam,\",\"tokenization_attempt_id\":\"illum dolor repellendus libero esse accusantium\",\"wallet_decisioning_info\":{\"device_score\":\"placeat architecto\"},\"digital_wallet_token_metadata\":{\"status\":\"reprehenderit dolor\",\"token_requestor_id\":\"possimus\",\"payment_account_info\":{\"account_holder_data\":{\"phone_number\":\"libero\",\"email_address\":\"nobis molestias, veniam culpa! quas elit. quas libero esse architecto placeat\"},\"pan_unique_reference\":\"adipisicing odit magnam, odit\"}}}" + val headers = + ImmutableListMultimap.of( + "webhook-id", + "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj", + "webhook-timestamp", + "1676312382", + "webhook-signature", + "v1,Dwa0AHInLL3XFo2sxcHamOQDrJNi7F654S3L6skMAOI=" + ) + + val event = client.webhooks().unwrap(payload, headers, null) + + assertThat(event).isNotNull() + } + + @Test + fun verifySignature() { + val client = + LithicOkHttpClient.builder() + .apiKey("test-api-key") + .webhookSecret("whsec_zlFsbBZ8Xcodlpcu6NDTdSzZRLSdhkst") + .clock(Clock.fixed(Instant.ofEpochSecond(1676312382), ZoneOffset.UTC)) + .build() + + val payload = + "{\"card_token\":\"sit Lorem ipsum, accusantium repellendus possimus\",\"created_at\":\"elit. placeat libero architecto molestias, sit\",\"account_token\":\"elit.\",\"issuer_decision\":\"magnam, libero esse Lorem ipsum magnam, magnam,\",\"tokenization_attempt_id\":\"illum dolor repellendus libero esse accusantium\",\"wallet_decisioning_info\":{\"device_score\":\"placeat architecto\"},\"digital_wallet_token_metadata\":{\"status\":\"reprehenderit dolor\",\"token_requestor_id\":\"possimus\",\"payment_account_info\":{\"account_holder_data\":{\"phone_number\":\"libero\",\"email_address\":\"nobis molestias, veniam culpa! quas elit. quas libero esse architecto placeat\"},\"pan_unique_reference\":\"adipisicing odit magnam, odit\"}}}" + val webhookId = "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj" + val webhookTimestamp = "1676312382" + val webhookSignature = "Dwa0AHInLL3XFo2sxcHamOQDrJNi7F654S3L6skMAOI=" + val headers = + ImmutableListMultimap.of( + "webhook-id", + webhookId, + "webhook-timestamp", + webhookTimestamp, + "webhook-signature", + "v1,$webhookSignature" + ) + + assertThatThrownBy { + client + .webhooks() + .verifySignature( + payload, + ImmutableListMultimap.of( + "webhook-id", + webhookId, + "webhook-timestamp", + "1676312022", + "webhook-signature", + "v1,$webhookSignature" + ), + null + ) + } + .isInstanceOf(LithicException::class.java) + .hasMessage("Webhook timestamp too old") + + assertThatThrownBy { + client + .webhooks() + .verifySignature( + payload, + ImmutableListMultimap.of( + "webhook-id", + webhookId, + "webhook-timestamp", + "1676312742", + "webhook-signature", + "v1,$webhookSignature" + ), + null + ) + } + .isInstanceOf(LithicException::class.java) + .hasMessage("Webhook timestamp too new") + + assertThatThrownBy { client.webhooks().verifySignature(payload, headers, "invalid-secret") } + .isInstanceOf(LithicException::class.java) + .hasMessage("Invalid webhook secret") + + assertThatThrownBy { client.webhooks().verifySignature(payload, headers, "Zm9v") } + .isInstanceOf(LithicException::class.java) + .hasMessage("None of the given webhook signatures match the expected signature") + + assertThatCode { + client + .webhooks() + .verifySignature( + payload, + ImmutableListMultimap.of( + "webhook-id", + webhookId, + "webhook-timestamp", + webhookTimestamp, + "webhook-signature", + "v1,$webhookSignature v1,Zm9v", + ), + null + ) + } + .doesNotThrowAnyException() + + assertThatThrownBy { + client + .webhooks() + .verifySignature( + payload, + ImmutableListMultimap.of( + "webhook-id", + webhookId, + "webhook-timestamp", + webhookTimestamp, + "webhook-signature", + "v2,$webhookSignature", + ), + null + ) + } + .isInstanceOf(LithicException::class.java) + .hasMessage("None of the given webhook signatures match the expected signature") + + assertThatCode { + client + .webhooks() + .verifySignature( + payload, + ImmutableListMultimap.of( + "webhook-id", + webhookId, + "webhook-timestamp", + webhookTimestamp, + "webhook-signature", + "v1,$webhookSignature v2,$webhookSignature", + ), + null + ) + } + .doesNotThrowAnyException() + + assertThatThrownBy { + client + .webhooks() + .verifySignature( + payload, + ImmutableListMultimap.of( + "webhook-id", + webhookId, + "webhook-timestamp", + webhookTimestamp, + "webhook-signature", + webhookSignature, + ), + null + ) + } + .isInstanceOf(LithicException::class.java) + .hasMessage("None of the given webhook signatures match the expected signature") + } +}