diff --git a/CODEOWNERS b/CODEOWNERS index 9da7c48b..daa65722 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -9,7 +9,7 @@ # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for everything in the repo. -* @mistermoe @phoebe-lew @jiyoontbd @michaelneale @KendallWeihe @ethan-tbd @tomdaffurn @diehuxx +* @mistermoe @phoebe-lew @jiyoontbd @michaelneale @KendallWeihe @ethan-tbd @tomdaffurn @diehuxx @kirahsapong # ----------------------------------------------- # BELOW THIS LINE ARE TEMPLATES, UNUSED diff --git a/httpclient/src/main/kotlin/tbdex/sdk/httpclient/TbdexHttpClient.kt b/httpclient/src/main/kotlin/tbdex/sdk/httpclient/TbdexHttpClient.kt index 95d711a8..8bf45009 100644 --- a/httpclient/src/main/kotlin/tbdex/sdk/httpclient/TbdexHttpClient.kt +++ b/httpclient/src/main/kotlin/tbdex/sdk/httpclient/TbdexHttpClient.kt @@ -5,8 +5,10 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import tbdex.sdk.httpclient.models.CreateExchangeRequest import tbdex.sdk.httpclient.models.ErrorDetail import tbdex.sdk.httpclient.models.Exchange import tbdex.sdk.httpclient.models.GetExchangesFilter @@ -14,7 +16,9 @@ import tbdex.sdk.httpclient.models.GetOfferingsFilter import tbdex.sdk.httpclient.models.TbdexResponseException import tbdex.sdk.protocol.Validator import tbdex.sdk.protocol.models.Message +import tbdex.sdk.protocol.models.MessageKind import tbdex.sdk.protocol.models.Offering +import tbdex.sdk.protocol.models.Rfq import tbdex.sdk.protocol.serialization.Json import tbdex.sdk.protocol.serialization.Json.jsonMapper import web5.sdk.dids.Did @@ -68,36 +72,71 @@ object TbdexHttpClient { } /** - * Sends a message to the PFI. + * Sends a message to the PFI. You can also use this message to create an exchange without a replyTo URL. * - * @param message The [Message] object containing the message details to be sent. - * @throws TbdexResponseException for request or response errors. + * @param message The message to send. + * + * @throws TbdexResponseException for response errors. */ fun sendMessage(message: Message) { - Validator.validateMessage(message) - message.verify() + validateMessage(message) val pfiDid = message.metadata.to val exchangeId = message.metadata.exchangeId val kind = message.metadata.kind val pfiServiceEndpoint = getPfiServiceEndpoint(pfiDid) - val url = "$pfiServiceEndpoint/exchanges/$exchangeId/$kind" + val url: String = if (kind == MessageKind.rfq) { + "$pfiServiceEndpoint/exchanges/$exchangeId" + } else { + "$pfiServiceEndpoint/exchanges/$exchangeId/$kind" + } - val body = Json.stringify(message).toRequestBody(jsonMediaType) + val body: RequestBody = Json.stringify(message).toRequestBody(jsonMediaType) - val request = Request.Builder() - .url(url) - .addHeader("Content-Type", JSON_HEADER) - .post(body) - .build() + val request = buildRequest(url, body) - println("attempting to send message to: ${request.url}") + println("Attempting to send $kind message to: ${request.url}") - val response: Response = client.newCall(request).execute() - if (!response.isSuccessful) { - throw buildResponseException(response) - } + executeRequest(request) + } + + /** + * Send RFQ message and include a replyTo URL for the PFI to send a callback to. + * + * @param message The message to send (is of type RFQ) + * @param replyTo The callback URL for PFI to send messages to. + * + * @throws TbdexResponseException for response errors. + * + */ + fun sendMessage(message: Rfq, replyTo: String) { + validateMessage(message) + + val pfiDid = message.metadata.to + val exchangeId = message.metadata.exchangeId + + val pfiServiceEndpoint = getPfiServiceEndpoint(pfiDid) + val url = "$pfiServiceEndpoint/exchanges/$exchangeId" + + val body: RequestBody = Json.stringify(CreateExchangeRequest(message, replyTo)) + .toRequestBody(jsonMediaType) + + val request = buildRequest(url, body) + + println("Attempting to send Rfq message to: ${request.url}") + + executeRequest(request) + } + + /** + * Aliased method for sendMessage(Rfq, String) to create an exchange by sending an RFQ with a replyTo URL. + * + * @param message The message to send (is of type RFQ) + * @param replyTo The callback URL for PFI to send messages to. + */ + fun createExchange(message: Rfq, replyTo: String) { + sendMessage(message, replyTo) } /** @@ -208,4 +247,25 @@ object TbdexHttpClient { errors = errors ) } + + private fun validateMessage(message: Message) { + Validator.validateMessage(message) + message.verify() + } + + private fun buildRequest(url: String, body: RequestBody): Request { + val request = Request.Builder() + .url(url) + .addHeader("Content-Type", JSON_HEADER) + .post(body) + .build() + return request + } + + private fun executeRequest(request: Request) { + val response: Response = client.newCall(request).execute() + if (!response.isSuccessful) { + throw buildResponseException(response) + } + } } diff --git a/httpclient/src/main/kotlin/tbdex/sdk/httpclient/models/CreateExchangeRequest.kt b/httpclient/src/main/kotlin/tbdex/sdk/httpclient/models/CreateExchangeRequest.kt new file mode 100644 index 00000000..1ab3e97c --- /dev/null +++ b/httpclient/src/main/kotlin/tbdex/sdk/httpclient/models/CreateExchangeRequest.kt @@ -0,0 +1,14 @@ +package tbdex.sdk.httpclient.models + +import tbdex.sdk.protocol.models.Rfq + +/** + * Data class used to type request body to create exchange via TbdexHttpServer. + * + * @property rfq Rfq tbdex message received. + * @property replyTo Optional URL to be included in the request to create exchange. + */ +class CreateExchangeRequest( + val rfq: Rfq, + val replyTo: String? = null +) \ No newline at end of file diff --git a/httpclient/src/test/kotlin/tbdex/sdk/httpclient/E2ETest.kt b/httpclient/src/test/kotlin/tbdex/sdk/httpclient/E2ETest.kt index 9ddf96e9..20ca144d 100644 --- a/httpclient/src/test/kotlin/tbdex/sdk/httpclient/E2ETest.kt +++ b/httpclient/src/test/kotlin/tbdex/sdk/httpclient/E2ETest.kt @@ -96,13 +96,14 @@ class E2ETest { val rfqData = buildRfqData(firstOfferingId, vcJwt) val rfq = Rfq.create(to = pfiDid, from = myDid.uri, rfqData) + rfq.sign(myDid) println("Sending RFQ against first offering id: ${offerings[0].metadata.id}.") println("ExchangeId for the rest of this exchange is ${rfq.metadata.exchangeId}") try { - client.sendMessage(rfq) + client.createExchange(rfq, "https://tbdex.io/callback") } catch (e: TbdexResponseException) { throw AssertionError( "Error in sending RFQ. " + @@ -124,6 +125,7 @@ class E2ETest { order.sign(myDid) println("Sending order against Quote with exchangeId of ${order.metadata.exchangeId}") + try { client.sendMessage(order) } catch (e: TbdexResponseException) { diff --git a/httpclient/src/test/kotlin/tbdex/sdk/httpclient/TbdexHttpClientTest.kt b/httpclient/src/test/kotlin/tbdex/sdk/httpclient/TbdexHttpClientTest.kt index 70e97202..094923f2 100644 --- a/httpclient/src/test/kotlin/tbdex/sdk/httpclient/TbdexHttpClientTest.kt +++ b/httpclient/src/test/kotlin/tbdex/sdk/httpclient/TbdexHttpClientTest.kt @@ -89,7 +89,16 @@ class TbdexHttpClientTest { server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_ACCEPTED)) val rfq = TestData.getRfq(pfiDid.uri, TypeId.generate("offering")) - assertDoesNotThrow { TbdexHttpClient.sendMessage(rfq) } + assertDoesNotThrow { TbdexHttpClient.sendMessage(rfq, "https://tbdex.io/callback") } + } + + @Test + fun `send RFQ with createExchange success via mockwebserver`() { + + server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_ACCEPTED)) + + val rfq = TestData.getRfq(pfiDid.uri, TypeId.generate("offering")) + assertDoesNotThrow { TbdexHttpClient.createExchange(rfq, "https://tbdex.io/callback") } } @Test @@ -109,10 +118,49 @@ class TbdexHttpClientTest { ) val mockResponseString = Json.jsonMapper.writeValueAsString(errorDetails) - server.enqueue(MockResponse().setBody(mockResponseString).setResponseCode(HttpURLConnection.HTTP_BAD_REQUEST)) + server + .enqueue( + MockResponse() + .setBody(mockResponseString) + .setResponseCode(HttpURLConnection.HTTP_BAD_REQUEST) + ) + + val rfq = TestData.getRfq(pfiDid.uri, TypeId.generate("offering")) + val exception = assertThrows { + TbdexHttpClient.sendMessage(rfq) + } + assertEquals(1, exception.errors?.size) + assertEquals("400", exception.errors?.get(0)?.status) + } + + @Test + fun `send RFQ with createExchange() fail via mockwebserver`() { + val errorDetails = mapOf( + "errors" to listOf( + ErrorDetail( + id = "1", + status = "400", + code = "INVALID_INPUT", + title = "Invalid Input", + detail = "The request input is invalid.", + source = null, + meta = null + ) + ) + ) + + val mockResponseString = Json.jsonMapper.writeValueAsString(errorDetails) + server + .enqueue( + MockResponse() + .setBody(mockResponseString) + .setResponseCode(HttpURLConnection.HTTP_BAD_REQUEST) + ) val rfq = TestData.getRfq(pfiDid.uri, TypeId.generate("offering")) - val exception = assertThrows { TbdexHttpClient.sendMessage(rfq) } + val exception = assertThrows { + TbdexHttpClient.createExchange(rfq, "https://tbdex.io/callback") + } assertEquals(1, exception.errors?.size) assertEquals("400", exception.errors?.get(0)?.status) } @@ -149,7 +197,9 @@ class TbdexHttpClientTest { val mockResponseString = Json.jsonMapper.writeValueAsString(errorDetails) server.enqueue(MockResponse().setBody(mockResponseString).setResponseCode(HttpURLConnection.HTTP_BAD_REQUEST)) - assertThrows { TbdexHttpClient.getExchange(pfiDid.uri, alice, "exchange_1234") } + assertThrows { + TbdexHttpClient.getExchange(pfiDid.uri, alice, "exchange_1234") + } } @Test diff --git a/httpserver/src/main/kotlin/tbdex/sdk/httpserver/TbdexHttpServer.kt b/httpserver/src/main/kotlin/tbdex/sdk/httpserver/TbdexHttpServer.kt index fbd57d62..f783d4ec 100644 --- a/httpserver/src/main/kotlin/tbdex/sdk/httpserver/TbdexHttpServer.kt +++ b/httpserver/src/main/kotlin/tbdex/sdk/httpserver/TbdexHttpServer.kt @@ -15,11 +15,11 @@ import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing +import tbdex.sdk.httpserver.handlers.createExchange import tbdex.sdk.httpserver.handlers.getExchanges import tbdex.sdk.httpserver.handlers.getOfferings import tbdex.sdk.httpserver.handlers.submitClose import tbdex.sdk.httpserver.handlers.submitOrder -import tbdex.sdk.httpserver.handlers.submitRfq import tbdex.sdk.httpserver.models.ExchangesApi import tbdex.sdk.httpserver.models.FakeExchangesApi import tbdex.sdk.httpserver.models.FakeOfferingsApi @@ -100,8 +100,8 @@ class TbdexHttpServer(private val config: TbdexHttpServerConfig) { } route("/exchanges") { - post("/{exchangeId}/rfq") { - submitRfq( + post("/{exchangeId}") { + createExchange( call = call, offeringsApi = offeringsApi, exchangesApi = exchangesApi, diff --git a/httpserver/src/main/kotlin/tbdex/sdk/httpserver/handlers/SubmitRfq.kt b/httpserver/src/main/kotlin/tbdex/sdk/httpserver/handlers/CreateExchange.kt similarity index 68% rename from httpserver/src/main/kotlin/tbdex/sdk/httpserver/handlers/SubmitRfq.kt rename to httpserver/src/main/kotlin/tbdex/sdk/httpserver/handlers/CreateExchange.kt index 28ae1624..da4276ca 100644 --- a/httpserver/src/main/kotlin/tbdex/sdk/httpserver/handlers/SubmitRfq.kt +++ b/httpserver/src/main/kotlin/tbdex/sdk/httpserver/handlers/CreateExchange.kt @@ -14,6 +14,8 @@ import tbdex.sdk.protocol.models.Message import tbdex.sdk.protocol.models.MessageKind import tbdex.sdk.protocol.models.Offering import tbdex.sdk.protocol.models.Rfq +import tbdex.sdk.protocol.serialization.Json +import java.net.URL /** * Handles the submission of a Request for Quote (RFQ) through the TBDex API. @@ -27,18 +29,34 @@ import tbdex.sdk.protocol.models.Rfq * @param callback An optional callback function to be invoked after processing the RFQ. */ @Suppress("TooGenericExceptionCaught", "SwallowedException") -suspend fun submitRfq( +suspend fun createExchange( call: ApplicationCall, offeringsApi: OfferingsApi, exchangesApi: ExchangesApi, callback: SubmitCallback? ) { val message: Rfq? - + val replyTo: String? try { - message = Message.parse(call.receiveText()) as Rfq + val requestBody = call.receiveText() + + val jsonNode = Json.jsonMapper.readTree(requestBody) + val rfqJsonString = jsonNode["rfq"].toString() + + message = Message.parse(rfqJsonString) as Rfq + // sets replyTo field to null if it's not present in the jsonNode. + // without .takeIf { !it.isNull }, jsonNode["replyTo"].asText() will set replyTo as "null" string. + replyTo = jsonNode["replyTo"].takeIf { !it.isNull }?.asText() + } catch (e: Exception) { - val errorDetail = ErrorDetail(detail = "Parsing of TBDex message failed: ${e.message}") + val errorDetail = ErrorDetail(detail = "Parsing of TBDex createExchange request failed: ${e.message}") + val errorResponse = ErrorResponse(listOf(errorDetail)) + call.respond(HttpStatusCode.BadRequest, errorResponse) + return + } + + if (replyTo != null && !isValidUrl(replyTo)) { + val errorDetail = ErrorDetail(detail = "replyTo must be a valid URL") val errorResponse = ErrorResponse(listOf(errorDetail)) call.respond(HttpStatusCode.BadRequest, errorResponse) return @@ -49,7 +67,8 @@ suspend fun submitRfq( val errorDetail = ErrorDetail(detail = "RFQ already exists.") call.respond(HttpStatusCode.Conflict, ErrorResponse(listOf(errorDetail))) return - } catch (_: Exception) { + } catch (_: NoSuchElementException) { + // exchangesApi.getExchange throws if no existing exchange is found } val offering: Offering @@ -84,4 +103,20 @@ suspend fun submitRfq( } call.respond(HttpStatusCode.Accepted) +} + +/** + * Checks if a string is a valid URL. + * + * @param replyToUrl The string to be checked. + * @return boolean indicating whether the string is a valid URL. + */ +@Suppress("SwallowedException") +fun isValidUrl(replyToUrl: String): Boolean { + return try { + URL(replyToUrl) + true + } catch (e: Exception) { + false + } } \ No newline at end of file diff --git a/httpserver/src/test/kotlin/tbdex/sdk/httpserver/handlers/SubmitRfqTest.kt b/httpserver/src/test/kotlin/tbdex/sdk/httpserver/handlers/CreateExchangeTest.kt similarity index 67% rename from httpserver/src/test/kotlin/tbdex/sdk/httpserver/handlers/SubmitRfqTest.kt rename to httpserver/src/test/kotlin/tbdex/sdk/httpserver/handlers/CreateExchangeTest.kt index 4c032085..87f77434 100644 --- a/httpserver/src/test/kotlin/tbdex/sdk/httpserver/handlers/SubmitRfqTest.kt +++ b/httpserver/src/test/kotlin/tbdex/sdk/httpserver/handlers/CreateExchangeTest.kt @@ -11,22 +11,23 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test +import tbdex.sdk.httpclient.models.CreateExchangeRequest import tbdex.sdk.httpclient.models.ErrorResponse import tbdex.sdk.protocol.serialization.Json import kotlin.test.assertContains import kotlin.test.assertEquals -class SubmitRfqTest : ServerTest() { +class CreateExchangeTest : ServerTest() { @Test fun `returns BadRequest if no request body is provided`() = runBlocking { - val response = client.post("/exchanges/123/rfq") { + val response = client.post("/exchanges/123") { contentType(ContentType.Application.Json) } val errorResponse = Json.jsonMapper.readValue(response.bodyAsText(), ErrorResponse::class.java) assertEquals(HttpStatusCode.BadRequest, response.status) - assertContains(errorResponse.errors.first().detail, "Parsing of TBDex message failed") + assertContains(errorResponse.errors.first().detail, "Parsing of TBDex createExchange request failed") } @Test @@ -35,9 +36,9 @@ class SubmitRfqTest : ServerTest() { rfq.sign(aliceDid) exchangesApi.addMessage(rfq) - val response = client.post("/exchanges/123/rfq") { + val response = client.post("/exchanges/123") { contentType(ContentType.Application.Json) - setBody(rfq) + setBody(CreateExchangeRequest(rfq)) } val errorResponse = Json.jsonMapper.readValue(response.bodyAsText(), ErrorResponse::class.java) @@ -51,9 +52,9 @@ class SubmitRfqTest : ServerTest() { val rfq = createRfq(null, listOf("foo")) rfq.sign(aliceDid) - val response = client.post("/exchanges/123/rfq") { + val response = client.post("/exchanges/123") { contentType(ContentType.Application.Json) - setBody(rfq) + setBody(CreateExchangeRequest(rfq)) } val errorResponse = Json.jsonMapper.readValue(response.bodyAsText(), ErrorResponse::class.java) @@ -62,14 +63,30 @@ class SubmitRfqTest : ServerTest() { assertContains(errorResponse.errors.first().detail, "Failed to verify offering requirements") } + @Test + fun `returns BadRequest if replyTo is an invalid URL`() = runBlocking { + val rfq = createRfq() + rfq.sign(aliceDid) + + val response = client.post("/exchanges/123") { + contentType(ContentType.Application.Json) + setBody(CreateExchangeRequest(rfq, "foo")) + } + + val errorResponse = Json.jsonMapper.readValue(response.bodyAsText(), ErrorResponse::class.java) + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertContains(errorResponse.errors.first().detail, "replyTo must be a valid URL") + } + @Test fun `returns Accepted if rfq is accepted`() = runBlocking { val rfq = createRfq(offeringsApi.getOffering("123")) rfq.sign(aliceDid) - val response = client.post("/exchanges/123/rfq") { + val response = client.post("/exchanges/123") { contentType(ContentType.Application.Json) - setBody(rfq) + setBody(CreateExchangeRequest(rfq, "http://localhost:9000/callback")) } assertEquals(HttpStatusCode.Accepted, response.status)