diff --git a/doc/_include/grpcurl.png b/doc/_include/grpcurl.png new file mode 100644 index 00000000..f8aae47f Binary files /dev/null and b/doc/_include/grpcurl.png differ diff --git a/doc/transports/grpc.md b/doc/transports/grpc.md index 4570f42f..b2d6d09e 100644 --- a/doc/transports/grpc.md +++ b/doc/transports/grpc.md @@ -34,15 +34,19 @@ End users input **JSON** payloads and read **JSON** responses. JSON data is converted to Protobuf for data transmission transparently. ## Updating an API Specification - On clicking the download schema button and the schema is successfully retrieved, it would **replace the one that has the name `{host}:{port}`** in the same Subproject. If there is none, even if a schema is selected, Hello HTTP would create a new one with this name. The name can be changed afterwards, and can be managed, as described below. ## Managing API Specifications - Click the pencil (Edit) button next to the current Subproject name. gRPC API specifications can be managed in the "Edit Subproject" dialog. ![Manage gRPC API Specifications](../manage-grpc-apispec.gif) +## Copy as `grpcurl` commands +A [grpcurl](https://github.com/fullstorydev/grpcurl) command can be copied to send the current request in a command +shell. All service method types are supported. If only response bodies are needed, the verbose option `-v` can be +removed. + +![grpcurl](../grpcurl.png) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/UserRequestConversionExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/UserRequestConversionExtension.kt index 13474abc..ac0e30fd 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/UserRequestConversionExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/UserRequestConversionExtension.kt @@ -9,6 +9,7 @@ import com.sunnychung.application.multiplatform.hellohttp.model.FormUrlEncodedBo import com.sunnychung.application.multiplatform.hellohttp.model.GraphqlBody import com.sunnychung.application.multiplatform.hellohttp.model.GraphqlRequestBody import com.sunnychung.application.multiplatform.hellohttp.model.GrpcApiSpec +import com.sunnychung.application.multiplatform.hellohttp.model.GrpcMethod import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest import com.sunnychung.application.multiplatform.hellohttp.model.MultipartBody import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication @@ -224,15 +225,15 @@ fun HttpRequest.toApacheHttpRequest(): Pair { return Pair(b.build(), entity?.contentLength ?: 0L) } -fun UserRequestTemplate.toCurlCommand(exampleId: String, environment: Environment?): String { - fun String.escape(): String { - return replace("\\", "\\\\").replace("\"", "\\\"") - } +private fun String.escape(): String { + return replace("\\", "\\\\").replace("\"", "\\\"") +} - fun String.urlEncoded(): String { - return URLEncoder.encode(this, StandardCharsets.UTF_8) - } +private fun String.urlEncoded(): String { + return URLEncoder.encode(this, StandardCharsets.UTF_8) +} +fun UserRequestTemplate.toCurlCommand(exampleId: String, environment: Environment?): String { val request = toHttpRequest(exampleId, environment) val url = request.getResolvedUri().toString() @@ -280,3 +281,35 @@ fun UserRequestTemplate.toCurlCommand(exampleId: String, environment: Environmen } return curl } + +fun UserRequestTemplate.toGrpcurlCommand(exampleId: String, environment: Environment?, payloadExampleId: String, method: GrpcMethod): String { + val request = toHttpRequest(exampleId, environment) + + val uri = request.getResolvedUri() + + val currentOS = currentOS() + val newLine = " ${currentOS.commandLineEscapeNewLine}\n " + + var cmd = "grpcurl -v" + + val isTlsConnection = uri.scheme !in setOf("http", "grpc") + if (isTlsConnection && environment?.sslConfig?.isInsecure == true) { + cmd += "${newLine}-insecure" + } else if (!isTlsConnection) { + cmd += "${newLine}-plaintext" + } + request.headers.forEach { + cmd += "${newLine}-H \"${it.first.escape()}: ${it.second.escape()}\"" + } + val payload = if (!method.isClientStreaming) { + (request.body as StringBody).value + } else { + payloadExamples!!.first { it.id == payloadExampleId }.body + } + cmd += "${newLine}-format json" + cmd += "${newLine}-d \"${payload.escape()}\"" + + cmd += "${newLine}${uri.host}:${uri.port}" + cmd += "${newLine}${grpc!!.service}/${grpc!!.method}" + return cmd +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt index 8b78517f..9698d951 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt @@ -46,6 +46,7 @@ import com.sunnychung.application.multiplatform.hellohttp.document.RequestCollec import com.sunnychung.application.multiplatform.hellohttp.document.RequestsDI import com.sunnychung.application.multiplatform.hellohttp.document.ResponsesDI import com.sunnychung.application.multiplatform.hellohttp.extension.toCurlCommand +import com.sunnychung.application.multiplatform.hellohttp.extension.toGrpcurlCommand import com.sunnychung.application.multiplatform.hellohttp.network.ConnectionStatus import com.sunnychung.application.multiplatform.hellohttp.model.ColourTheme import com.sunnychung.application.multiplatform.hellohttp.model.Environment @@ -475,6 +476,17 @@ fun AppContentView() { false } }, + onClickCopyGrpcurl = { payloadExampleId, grpcMethod -> + val cmd = requestNonNull.toGrpcurlCommand( + exampleId = selectedRequestExampleId!!, + environment = selectedEnvironment, + payloadExampleId = payloadExampleId, + method = grpcMethod, + ) + log.d { "grpcurl: $cmd" } + clipboardManager.setText(AnnotatedString(cmd)) + true + }, onRequestModified = { log.d { "onRequestModified" } it?.let { update -> diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index fb24977c..a321fb12 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -51,6 +51,7 @@ import com.sunnychung.application.multiplatform.hellohttp.model.FileBody import com.sunnychung.application.multiplatform.hellohttp.model.FormUrlEncodedBody import com.sunnychung.application.multiplatform.hellohttp.model.GraphqlBody import com.sunnychung.application.multiplatform.hellohttp.model.GrpcApiSpec +import com.sunnychung.application.multiplatform.hellohttp.model.GrpcMethod import com.sunnychung.application.multiplatform.hellohttp.model.MultipartBody import com.sunnychung.application.multiplatform.hellohttp.model.PayloadExample import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication @@ -91,6 +92,7 @@ fun RequestEditorView( onClickSend: () -> Unit, onClickCancel: () -> Unit, onClickCopyCurl: () -> Boolean, + onClickCopyGrpcurl: (selectedPayloadExampleId: String, method: GrpcMethod) -> Boolean, onRequestModified: (UserRequestTemplate?) -> Unit, connectionStatus: ConnectionStatus, onClickConnect: () -> Unit, @@ -127,6 +129,7 @@ fun RequestEditorView( val hasPayloadEditor = (request.application == ProtocolApplication.WebSocket || (request.application == ProtocolApplication.Grpc && currentGrpcMethod?.isClientStreaming == true) ) + var selectedPayloadExampleId by remember { mutableStateOf(request.payloadExamples?.firstOrNull()?.id) } log.d { "RequestEditorView recompose $request" } @@ -284,6 +287,14 @@ fun RequestEditorView( "Copy as cURL command" -> { isSuccess = onClickCopyCurl() } + "Copy as grpcurl command" -> { + isSuccess = try { + onClickCopyGrpcurl(selectedPayloadExampleId!!, currentGrpcMethod!!) + } catch (e: Throwable) { + log.d(e) { "Cannot copy grpcurl command" } + false + } + } } isSuccess }, @@ -597,6 +608,8 @@ fun RequestEditorView( modifier = Modifier.weight(0.7f).fillMaxWidth(), request = request, onRequestModified = onRequestModified, + selectedPayloadExampleId = selectedPayloadExampleId!!, + onSelectExample = { selectedPayloadExampleId = it.id }, hasCompleteButton = request.application == ProtocolApplication.Grpc && currentGrpcMethod?.isClientStreaming == true, knownVariables = environmentVariableKeys, onClickSendPayload = onClickSendPayload, @@ -1191,6 +1204,8 @@ fun StreamingPayloadEditorView( editExampleNameViewModel: EditNameViewModel = remember { EditNameViewModel() }, request: UserRequestTemplate, onRequestModified: (UserRequestTemplate?) -> Unit, + selectedPayloadExampleId: String, + onSelectExample: (PayloadExample) -> Unit, hasCompleteButton: Boolean, knownVariables: Set, onClickSendPayload: (String) -> Unit, @@ -1199,16 +1214,13 @@ fun StreamingPayloadEditorView( ) { val colors = LocalColor.current - var selectedExampleId by remember { mutableStateOf(request.payloadExamples!!.first().id) } - var selectedExample = request.payloadExamples!!.firstOrNull { it.id == selectedExampleId } - - fun onSelectExample(example: PayloadExample) { - selectedExampleId = example.id - selectedExample = example - } + var selectedExample = request.payloadExamples!!.firstOrNull { it.id == selectedPayloadExampleId } if (selectedExample == null) { - onSelectExample(request.payloadExamples.first()) + request.payloadExamples.first().let { + onSelectExample(it) + selectedExample = it + } } Column(modifier) {