From 89e5d4f235a11d698b0e65f406f338b445f32f52 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 29 Jan 2024 20:50:43 -0800 Subject: [PATCH 01/41] Hooked up test call from frontend! --- src-tauri/Cargo.lock | 324 +++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/grpc/Cargo.toml | 18 ++ src-tauri/grpc/src/codec.rs | 52 +++++ src-tauri/grpc/src/json_schema.rs | 175 ++++++++++++++++ src-tauri/grpc/src/lib.rs | 79 ++++++++ src-tauri/grpc/src/proto.rs | 137 +++++++++++++ src-tauri/src/main.rs | 28 +++ src-web/main.tsx | 15 ++ 9 files changed, 821 insertions(+), 9 deletions(-) create mode 100644 src-tauri/grpc/Cargo.toml create mode 100644 src-tauri/grpc/src/codec.rs create mode 100644 src-tauri/grpc/src/json_schema.rs create mode 100644 src-tauri/grpc/src/lib.rs create mode 100644 src-tauri/grpc/src/proto.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 88fa03eb..c798866a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.75" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" [[package]] name = "async-compression" @@ -95,6 +95,39 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "async-trait" +version = "0.1.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "atk" version = "0.15.1" @@ -134,6 +167,51 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa 1.0.9", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -1596,6 +1674,24 @@ dependencies = [ "system-deps 6.2.0", ] +[[package]] +name = "grpc" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "once_cell", + "prost", + "prost-reflect", + "prost-types", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tonic", + "tonic-reflection", +] + [[package]] name = "gtk" version = "0.15.5" @@ -1853,6 +1949,18 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -2423,6 +2531,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -2839,6 +2953,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "os_info" version = "3.7.0" @@ -3074,6 +3197,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "pin-project-lite" version = "0.2.13" @@ -3230,6 +3373,64 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "prost-reflect" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "057237efdb71cf4b3f9396302a3d6599a92fa94063ba537b66130980ea9909f3" +dependencies = [ + "base64 0.21.5", + "once_cell", + "prost", + "prost-reflect-derive", + "prost-types", + "serde", + "serde-value", +] + +[[package]] +name = "prost-reflect-derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172da1212c02be2c94901440cb27183cd92bff00ebacca5c323bf7520b8f9c04" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "prost-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" +dependencies = [ + "prost", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -3736,18 +3937,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" dependencies = [ "serde_derive", ] [[package]] -name = "serde_derive" -version = "1.0.195" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" dependencies = [ "proc-macro2", "quote", @@ -3756,9 +3967,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.111" +version = "1.0.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" dependencies = [ "itoa 1.0.9", "ryu", @@ -4314,6 +4525,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "synstructure" version = "0.13.0" @@ -4829,9 +5046,31 @@ dependencies = [ "num_cpus", "pin-project-lite", "socket2 0.5.5", + "tokio-macros", "windows-sys 0.48.0", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -4935,6 +5174,72 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.21.5", + "bytes", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-reflection" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa37c513df1339d197f4ba21d28c918b9ef1ac1768265f11ecb6b7f1cba1b76" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + [[package]] name = "tower-service" version = "0.3.2" @@ -5925,6 +6230,7 @@ dependencies = [ "cookie 0.18.0", "datetime", "futures", + "grpc", "http", "log", "objc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f1f1d448..da9dfecf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,3 +1,4 @@ +workspace = { members = ["grpc"] } [package] name = "yaak-app" version = "0.0.0" @@ -59,6 +60,7 @@ log = "0.4.20" datetime = "0.5.2" window-shadows = "0.2.2" reqwest_cookie_store = "0.6.0" +grpc = { path = "./grpc" } [features] # by default Tauri runs in production mode diff --git a/src-tauri/grpc/Cargo.toml b/src-tauri/grpc/Cargo.toml new file mode 100644 index 00000000..88137351 --- /dev/null +++ b/src-tauri/grpc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "grpc" +version = "0.1.0" +edition = "2021" + +[dependencies] +tonic = "0.10.2" +prost = "0.12" +tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } +tonic-reflection = "0.10.2" +tokio-stream = "0.1.14" +prost-types = "0.12.3" +serde = { version = "1.0.196", features = ["derive"] } +serde_json = "1.0.113" +prost-reflect = { version = "0.12.0", features = ["serde", "derive"] } +log = "0.4.20" +once_cell = { version = "1.19.0", features = [] } +anyhow = "1.0.79" diff --git a/src-tauri/grpc/src/codec.rs b/src-tauri/grpc/src/codec.rs new file mode 100644 index 00000000..f01ce87d --- /dev/null +++ b/src-tauri/grpc/src/codec.rs @@ -0,0 +1,52 @@ +use prost_reflect::prost::Message; +use prost_reflect::{DynamicMessage, MethodDescriptor}; +use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}; +use tonic::Status; + +#[derive(Clone)] +pub struct DynamicCodec(MethodDescriptor); + +impl DynamicCodec { + #[allow(dead_code)] + pub fn new(md: MethodDescriptor) -> Self { + Self(md) + } +} + +impl Codec for DynamicCodec { + type Encode = DynamicMessage; + type Decode = DynamicMessage; + type Encoder = Self; + type Decoder = Self; + + fn encoder(&mut self) -> Self::Encoder { + self.clone() + } + + fn decoder(&mut self) -> Self::Decoder { + self.clone() + } +} + +impl Encoder for DynamicCodec { + type Item = DynamicMessage; + type Error = Status; + + fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> { + item.encode(dst) + .expect("buffer is too small to decode this message"); + Ok(()) + } +} + +impl Decoder for DynamicCodec { + type Item = DynamicMessage; + type Error = Status; + + fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result, Self::Error> { + let mut msg = DynamicMessage::new(self.0.output()); + msg.merge(src) + .map_err(|err| Status::internal(err.to_string()))?; + Ok(Some(msg)) + } +} diff --git a/src-tauri/grpc/src/json_schema.rs b/src-tauri/grpc/src/json_schema.rs new file mode 100644 index 00000000..3d859d17 --- /dev/null +++ b/src-tauri/grpc/src/json_schema.rs @@ -0,0 +1,175 @@ +use std::collections::HashMap; +use prost_reflect::{DescriptorPool, MessageDescriptor}; +use prost_types::field_descriptor_proto; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct JsonSchemaEntry { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + + #[serde(rename = "type")] + type_: JsonType, + + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + properties: Option>, + + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + enum_: Option>, + + /// Don't allow any other properties in the object + #[serde(skip_serializing_if = "Option::is_none")] + additional_properties: Option, + + /// Set all properties to required + #[serde(skip_serializing_if = "Option::is_none")] + required: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + items: Option>, +} + +enum JsonType { + String, + Number, + Object, + Array, + Boolean, + Null, + _UNKNOWN, +} + +impl Default for JsonType { + fn default() -> Self { + JsonType::_UNKNOWN + } +} + +impl serde::Serialize for JsonType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + JsonType::String => serializer.serialize_str("string"), + JsonType::Number => serializer.serialize_str("number"), + JsonType::Object => serializer.serialize_str("object"), + JsonType::Array => serializer.serialize_str("array"), + JsonType::Boolean => serializer.serialize_str("boolean"), + JsonType::Null => serializer.serialize_str("null"), + JsonType::_UNKNOWN => serializer.serialize_str("unknown"), + } + } +} + +impl<'de> serde::Deserialize<'de> for JsonType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "string" => Ok(JsonType::String), + "number" => Ok(JsonType::Number), + "object" => Ok(JsonType::Object), + "array" => Ok(JsonType::Array), + "boolean" => Ok(JsonType::Boolean), + "null" => Ok(JsonType::Null), + _ => Ok(JsonType::_UNKNOWN), + } + } +} + +pub fn message_to_json_schema(pool: &DescriptorPool, message: MessageDescriptor) -> JsonSchemaEntry { + let mut schema = JsonSchemaEntry { + title: Some(message.name().to_string()), + type_: JsonType::Object, // Messages are objects + ..Default::default() + }; + + let mut properties = HashMap::new(); + message.fields().for_each(|f| match f.kind() { + prost_reflect::Kind::Message(m) => { + properties.insert(f.name().to_string(), message_to_json_schema(pool, m)); + } + prost_reflect::Kind::Enum(e) => { + properties.insert( + f.name().to_string(), + JsonSchemaEntry { + type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()), + enum_: Some(e.values().map(|v| v.name().to_string()).collect::>()), + ..Default::default() + }, + ); + } + _ => { + // TODO: Handle repeated label + match f.field_descriptor_proto().label() { + field_descriptor_proto::Label::Repeated => { + // TODO: Handle more complex repeated types. This just handles primitives for now + properties.insert( + f.name().to_string(), + JsonSchemaEntry { + type_: JsonType::Array, + items: Some(Box::new(JsonSchemaEntry { + type_: map_proto_type_to_json_type( + f.field_descriptor_proto().r#type(), + ), + ..Default::default() + })), + ..Default::default() + }, + ); + } + _ => { + // Regular JSON field + properties.insert( + f.name().to_string(), + JsonSchemaEntry { + type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()), + ..Default::default() + }, + ); + } + }; + } + }); + + schema.properties = Some(properties); + + schema.required = Some( + message + .fields() + .map(|f| f.name().to_string()) + .collect::>(), + ); + + schema +} + +fn map_proto_type_to_json_type(proto_type: field_descriptor_proto::Type) -> JsonType { + match proto_type { + field_descriptor_proto::Type::Double => JsonType::Number, + field_descriptor_proto::Type::Float => JsonType::Number, + field_descriptor_proto::Type::Int64 => JsonType::Number, + field_descriptor_proto::Type::Uint64 => JsonType::Number, + field_descriptor_proto::Type::Int32 => JsonType::Number, + field_descriptor_proto::Type::Fixed64 => JsonType::Number, + field_descriptor_proto::Type::Fixed32 => JsonType::Number, + field_descriptor_proto::Type::Bool => JsonType::Boolean, + field_descriptor_proto::Type::String => JsonType::String, + field_descriptor_proto::Type::Group => JsonType::_UNKNOWN, + field_descriptor_proto::Type::Message => JsonType::Object, + field_descriptor_proto::Type::Bytes => JsonType::String, + field_descriptor_proto::Type::Uint32 => JsonType::Number, + field_descriptor_proto::Type::Enum => JsonType::String, + field_descriptor_proto::Type::Sfixed32 => JsonType::Number, + field_descriptor_proto::Type::Sfixed64 => JsonType::Number, + field_descriptor_proto::Type::Sint32 => JsonType::Number, + field_descriptor_proto::Type::Sint64 => JsonType::Number, + } +} diff --git a/src-tauri/grpc/src/lib.rs b/src-tauri/grpc/src/lib.rs new file mode 100644 index 00000000..2668fb29 --- /dev/null +++ b/src-tauri/grpc/src/lib.rs @@ -0,0 +1,79 @@ +use prost_reflect::DynamicMessage; +use serde::{Deserialize, Serialize}; +use serde_json::Deserializer; +use tonic::IntoRequest; +use tonic::transport::Uri; + +use crate::codec::DynamicCodec; +use crate::proto::{fill_pool, method_desc_to_path}; + +mod codec; +mod json_schema; +mod proto; + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct ServiceDefinition { + pub name: String, + pub methods: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct MethodDefinition { + pub name: String, + pub schema: String, +} + +pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> String { + let (pool, conn) = fill_pool(uri).await; + + let service = pool.get_service_by_name(service).unwrap(); + let method = &service.methods().find(|m| m.name() == method).unwrap(); + let input_message = method.input(); + + let mut deserializer = Deserializer::from_str(message_json); + let req_message = DynamicMessage::deserialize(input_message, &mut deserializer).unwrap(); + deserializer.end().unwrap(); + + let mut client = tonic::client::Grpc::new(conn); + + println!( + "\n---------- SENDING -----------------\n{}", + serde_json::to_string_pretty(&req_message).expect("json") + ); + + let req = req_message.into_request(); + let path = method_desc_to_path(method); + let codec = DynamicCodec::new(method.clone()); + client.ready().await.unwrap(); + + let resp = client.unary(req, path, codec).await.unwrap(); + let response_json = serde_json::to_string_pretty(&resp.into_inner()).expect("json to string"); + println!("\n---------- RECEIVING ---------------\n{}", response_json,); + + response_json +} + +pub async fn callable(uri: &Uri) -> Vec { + let (pool, _) = fill_pool(uri).await; + + pool.services() + .map(|s| { + let mut def = ServiceDefinition { + name: s.full_name().to_string(), + ..Default::default() + }; + for method in s.methods() { + let input_message = method.input(); + def.methods.push(MethodDefinition { + name: method.name().to_string(), + schema: serde_json::to_string_pretty(&json_schema::message_to_json_schema( + &pool, + input_message, + )) + .unwrap(), + }) + } + def + }) + .collect::>() +} diff --git a/src-tauri/grpc/src/proto.rs b/src-tauri/grpc/src/proto.rs new file mode 100644 index 00000000..dbd59a55 --- /dev/null +++ b/src-tauri/grpc/src/proto.rs @@ -0,0 +1,137 @@ +use std::ops::Deref; +use std::str::FromStr; +use anyhow::anyhow; +use prost::Message; +use prost_reflect::{DescriptorPool, MethodDescriptor}; +use prost_types::FileDescriptorProto; +use tokio_stream::StreamExt; +use tonic::codegen::http::uri::PathAndQuery; +use tonic::Request; +use tonic::transport::{Channel, Uri}; +use tonic_reflection::pb::server_reflection_client::ServerReflectionClient; +use tonic_reflection::pb::server_reflection_request::MessageRequest; +use tonic_reflection::pb::server_reflection_response::MessageResponse; +use tonic_reflection::pb::ServerReflectionRequest; + +pub async fn fill_pool(uri: &Uri) -> (DescriptorPool, Channel) { + let mut pool = DescriptorPool::new(); + let conn = tonic::transport::Endpoint::new(uri.clone()) + .unwrap() + .connect() + .await + .unwrap(); + + let mut client = ServerReflectionClient::new(conn.clone()); + let services = list_services(&mut client).await; + + for service in services { + if service == "grpc.reflection.v1alpha.ServerReflection" { + continue; + } + file_descriptor_set_from_service_name(&service, &mut pool, &mut client).await; + } + + (pool, conn) +} + +async fn list_services(reflect_client: &mut ServerReflectionClient) -> Vec { + let response = + send_reflection_request(reflect_client, MessageRequest::ListServices("".into())).await; + + let list_services_response = match response { + MessageResponse::ListServicesResponse(resp) => resp, + _ => panic!("Expected a ListServicesResponse variant"), + }; + + list_services_response + .service + .iter() + .map(|s| s.name.clone()) + .collect::>() +} + +async fn file_descriptor_set_from_service_name( + service_name: &str, + pool: &mut DescriptorPool, + client: &mut ServerReflectionClient, +) { + let response = send_reflection_request( + client, + MessageRequest::FileContainingSymbol(service_name.into()), + ) + .await; + + let file_descriptor_response = match response { + MessageResponse::FileDescriptorResponse(resp) => resp, + _ => panic!("Expected a FileDescriptorResponse variant"), + }; + + for fd in file_descriptor_response.file_descriptor_proto { + let fdp = FileDescriptorProto::decode(fd.deref()).unwrap(); + + // Add deps first or else we'll get an error + for dep_name in fdp.clone().dependency { + file_descriptor_set_by_filename(&dep_name, pool, client).await; + } + + pool.add_file_descriptor_proto(fdp) + .expect("add file descriptor proto"); + } +} + +async fn file_descriptor_set_by_filename( + filename: &str, + pool: &mut DescriptorPool, + client: &mut ServerReflectionClient, +) { + // We already fetched this file + if let Some(_) = pool.get_file_by_name(filename) { + return; + } + + let response = + send_reflection_request(client, MessageRequest::FileByFilename(filename.into())).await; + let file_descriptor_response = match response { + MessageResponse::FileDescriptorResponse(resp) => resp, + _ => panic!("Expected a FileDescriptorResponse variant"), + }; + + for fd in file_descriptor_response.file_descriptor_proto { + let fdp = FileDescriptorProto::decode(fd.deref()).unwrap(); + pool.add_file_descriptor_proto(fdp) + .expect("add file descriptor proto"); + } +} + +async fn send_reflection_request( + client: &mut ServerReflectionClient, + message: MessageRequest, +) -> MessageResponse { + let reflection_request = ServerReflectionRequest { + host: "".into(), // Doesn't matter + message_request: Some(message), + }; + + let request = Request::new(tokio_stream::once(reflection_request)); + + client + .server_reflection_info(request) + .await + .expect("server reflection failed") + .into_inner() + .next() + .await + .expect("steamed response") + .expect("successful response") + .message_response + .expect("some MessageResponse") +} + +pub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery { + let full_name = md.full_name(); + let (namespace, method_name) = full_name + .rsplit_once('.') + .ok_or_else(|| anyhow!("invalid method path")) + .expect("invalid method path"); + PathAndQuery::from_str(&format!("/{}/{}", namespace, method_name)).expect("invalid method path") +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f29dacc9..92d69ec6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,12 +8,15 @@ extern crate core; #[macro_use] extern crate objc; +use ::http::Uri; use std::collections::HashMap; use std::env::current_dir; use std::fs::{create_dir_all, read_to_string, File}; use std::process::exit; +use std::str::FromStr; use fern::colors::ColoredLevelConfig; +use grpc::ServiceDefinition; use log::{debug, error, info, warn}; use rand::random; use serde::Serialize; @@ -75,6 +78,29 @@ async fn migrate_db( Ok(()) } +#[tauri::command] +async fn grpc_reflect( + endpoint: &str, + // app_handle: AppHandle, + // db_instance: State<'_, Mutex>>, +) -> Result, String> { + let uri = Uri::from_str(endpoint).map_err(|e| e.to_string())?; + Ok(grpc::callable(&uri).await) +} + +#[tauri::command] +async fn grpc_call_unary( + endpoint: &str, + service: &str, + method: &str, + message: &str, + // app_handle: AppHandle, + // db_instance: State<'_, Mutex>>, +) -> Result { + let uri = Uri::from_str(endpoint).map_err(|e| e.to_string())?; + Ok(grpc::call(&uri, service, method, message).await) +} + #[tauri::command] async fn send_ephemeral_request( mut request: models::HttpRequest, @@ -977,6 +1003,8 @@ fn main() { get_request, get_settings, get_workspace, + grpc_call_unary, + grpc_reflect, import_data, list_cookie_jars, list_environments, diff --git a/src-web/main.tsx b/src-web/main.tsx index 0819069d..f2a7491a 100644 --- a/src-web/main.tsx +++ b/src-web/main.tsx @@ -1,3 +1,4 @@ +import { invoke } from '@tauri-apps/api'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { attachConsole } from 'tauri-plugin-log-api'; @@ -10,6 +11,20 @@ import { setAppearanceOnDocument } from './lib/theme/window'; import { appWindow } from '@tauri-apps/api/window'; import { type } from '@tauri-apps/api/os'; +try { + const services: any = await invoke('grpc_reflect', { endpoint: 'http://localhost:50051' }); + console.log('SERVICES', services); + const response = await invoke('grpc_call_unary', { + endpoint: 'http://localhost:50051', + service: services[0].name, + method: services[0].methods[0].name, + message: '{"name": "Greg"}', + }); + console.log('RESPONSE', response); +} catch (err) { + console.log('ERROR', err); +} + // Hide decorations here because it doesn't work in Rust for some reason (bug?) const osType = await type(); if (osType !== 'Darwin') { From 9426885bb89208e93952f5cb35d02dcc85f1bb88 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 30 Jan 2024 16:43:54 -0800 Subject: [PATCH 02/41] Initial frontend for gRPC UI --- package-lock.json | 302 ++++++++++++++++-- package.json | 2 + src-tauri/src/main.rs | 12 +- src-tauri/src/models.rs | 84 ++++- src-web/components/GraphQLEditor.tsx | 4 +- src-web/components/GrpcConnectionLayout.tsx | 81 +++++ src-web/components/GrpcEditor.tsx | 40 +++ src-web/components/HttpRequestLayout.tsx | 21 ++ src-web/components/RequestPane.tsx | 33 +- src-web/components/UrlBar.tsx | 72 ++--- src-web/components/Workspace.tsx | 11 +- src-web/components/core/Editor/extensions.ts | 4 +- .../SplitLayout.tsx} | 53 +-- src-web/hooks/useCreateCookieJar.ts | 14 +- src-web/hooks/useGrpc.ts | 35 ++ src-web/main.tsx | 14 - 16 files changed, 650 insertions(+), 132 deletions(-) create mode 100644 src-web/components/GrpcConnectionLayout.tsx create mode 100644 src-web/components/GrpcEditor.tsx create mode 100644 src-web/components/HttpRequestLayout.tsx rename src-web/components/{RequestResponse.tsx => core/SplitLayout.tsx} (74%) create mode 100644 src-web/hooks/useGrpc.ts diff --git a/package-lock.json b/package-lock.json index 48ded9bf..42b4fa10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "classnames": "^2.3.2", "cm6-graphql": "^0.0.9", "codemirror": "^6.0.1", + "codemirror-json-schema": "^0.6.1", + "codemirror-json5": "^1.0.3", "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", @@ -469,6 +471,30 @@ "node": ">=6.9.0" } }, + "node_modules/@changesets/changelog-github": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@changesets/changelog-github/-/changelog-github-0.4.8.tgz", + "integrity": "sha512-jR1DHibkMAb5v/8ym77E4AMNWZKB5NPzw5a5Wtqm1JepAuIF+hrKp2u04NKM14oBZhHglkCfrla9uq8ORnK/dw==", + "dependencies": { + "@changesets/get-github-info": "^0.5.2", + "@changesets/types": "^5.2.1", + "dotenv": "^8.1.0" + } + }, + "node_modules/@changesets/get-github-info": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@changesets/get-github-info/-/get-github-info-0.5.2.tgz", + "integrity": "sha512-JppheLu7S114aEs157fOZDjFqUDpm7eHdq5E8SSR0gUBTEK0cNSHsrSR5a66xs0z3RWuo46QvA3vawp8BxDHvg==", + "dependencies": { + "dataloader": "^1.4.0", + "node-fetch": "^2.5.0" + } + }, + "node_modules/@changesets/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-5.2.1.tgz", + "integrity": "sha512-myLfHbVOqaq9UtUKqR/nZA/OY7xFjQMdfgfqeZIBK4d0hA6pgxArvdv8M+6NUzzBsjWLOtvApv8YHr4qM+Kpfg==" + }, "node_modules/@codemirror/autocomplete": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.2.0.tgz", @@ -511,16 +537,6 @@ "@lezer/javascript": "^1.0.0" } }, - "node_modules/@codemirror/lang-javascript/node_modules/@codemirror/view": { - "version": "6.21.4", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.21.4.tgz", - "integrity": "sha512-WKVZ7nvN0lwWPfAf05WxWqTpwjC8YN3q5goj3CsSig7//DD81LULgOx3nBegqpqP0iygBqRmW8z0KSc2QTAdAg==", - "dependencies": { - "@codemirror/state": "^6.1.4", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, "node_modules/@codemirror/lang-json": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", @@ -556,9 +572,9 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.2.1.tgz", - "integrity": "sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.4.2.tgz", + "integrity": "sha512-wzRkluWb1ptPKdzlsrbwwjYCPLgzU6N88YBAmlZi8WFyuiEduSd05MnJYNogzyc8rPK7pj6m95ptUApc8sHKVA==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -576,17 +592,17 @@ } }, "node_modules/@codemirror/state": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.0.tgz", - "integrity": "sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz", + "integrity": "sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==" }, "node_modules/@codemirror/view": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.2.1.tgz", - "integrity": "sha512-r1svbtAj2Lp/86F3yy1TfDAOAtJRGLINLSEqByETyUaGo1EnLS+P+bbGCVHV62z46BzZYm16noDid69+4bzn0g==", + "version": "6.23.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.1.tgz", + "integrity": "sha512-J2Xnn5lFYT1ZN/5ewEoMBCmLlL71lZ3mBdb7cUEuHhX2ESoSrNEucpsDXpX22EuTGm9LOgC9v4Z0wx+Ez8QmGA==", "dependencies": { - "@codemirror/state": "^6.0.0", - "style-mod": "^4.0.0", + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, @@ -1350,6 +1366,20 @@ } } }, + "node_modules/@sagold/json-pointer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sagold/json-pointer/-/json-pointer-5.1.1.tgz", + "integrity": "sha512-/iskWuyGNu09qy09HYmyLnvzpKryymH9T+vTBi2LdFp1TuKvERDADvPMv2ZkQKsrRklOzivmOz9QXof0dKqvgA==" + }, + "node_modules/@sagold/json-query": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sagold/json-query/-/json-query-6.1.1.tgz", + "integrity": "sha512-5/Wu0rTnXmO5Uvtm9Of16Vx3mKjSnYA0Um9LgBtyPhIucYlppKgKC4N3g8gD0Fk00a5kizQTs4gwxKPXCpmeww==", + "dependencies": { + "@sagold/json-pointer": "^5.1.1", + "ebnf": "^1.9.1" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -2174,8 +2204,7 @@ "node_modules/@types/json-schema": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", - "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", - "dev": true + "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -3537,6 +3566,53 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/codemirror-json-schema": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/codemirror-json-schema/-/codemirror-json-schema-0.6.1.tgz", + "integrity": "sha512-QG12Jy917eStZzxurpAE9QUQxF8SS/AYJ9DDteyJZcRGH8ePaBCfQ4KLCNtY6cUKjFeNBgcd5+c6FPAri6pPQg==", + "dependencies": { + "@changesets/changelog-github": "^0.4.8", + "@sagold/json-pointer": "^5.1.1", + "@types/json-schema": "^7.0.12", + "@types/node": "^20.4.2", + "json-schema": "^0.4.0", + "json-schema-library": "^9.1.2" + }, + "optionalDependencies": { + "@codemirror/lang-json": "^6.0.1", + "codemirror-json5": "^1.0.3", + "json5": "^2.2.3" + }, + "peerDependencies": { + "@codemirror/language": "^6.8.0", + "@codemirror/lint": "^6.4.0", + "@codemirror/state": "^6.2.1", + "@codemirror/view": "^6.14.1", + "@lezer/common": "^1.0.3" + } + }, + "node_modules/codemirror-json-schema/node_modules/@types/node": { + "version": "20.11.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.10.tgz", + "integrity": "sha512-rZEfe/hJSGYmdfX9tvcPMYeYPW2sNl50nsw4jZmRcaG0HIAb0WYEpsB05GOb53vjqpyE9GUhlDQ4jLSoB5q9kg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/codemirror-json5": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/codemirror-json5/-/codemirror-json5-1.0.3.tgz", + "integrity": "sha512-HmmoYO2huQxoaoG5ARKjqQc9mz7/qmNPvMbISVfIE2Gk1+4vZQg9X3G6g49MYM5IK00Ol3aijd7OKrySuOkA7Q==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "json5": "^2.2.1", + "lezer-json5": "^2.0.2" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3724,6 +3800,11 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/dataloader": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz", + "integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3803,6 +3884,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -3885,6 +3974,11 @@ "node": ">=8" } }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -3924,6 +4018,14 @@ "node": ">=4" } }, + "node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "engines": { + "node": ">=10" + } + }, "node_modules/duplexer3": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", @@ -3936,6 +4038,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ebnf": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ebnf/-/ebnf-1.9.1.tgz", + "integrity": "sha512-uW2UKSsuty9ANJ3YByIQE4ANkD8nqUPO7r6Fwcc1ADKPe9FRdcPpMl3VEput4JSvKBJ4J86npIC2MLP0pYkCuw==", + "bin": { + "ebnf": "dist/bin.js" + } + }, "node_modules/electron": { "version": "23.3.13", "resolved": "https://registry.npmjs.org/electron/-/electron-23.3.13.tgz", @@ -4777,6 +4887,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fast-copy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", + "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6097,6 +6212,25 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, + "node_modules/json-schema-library": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/json-schema-library/-/json-schema-library-9.1.2.tgz", + "integrity": "sha512-uQnFb2V+VakLl6XIGGtUQzfjkP31f/dCT5lJq9NOUdypSSpjbWL/V0R2KvoNJp3hU8VErwh9DqVoZPqlC+B3IA==", + "dependencies": { + "@sagold/json-pointer": "^5.1.1", + "@sagold/json-query": "^6.1.1", + "deepmerge": "^4.3.1", + "fast-copy": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "smtp-address-parser": "1.0.10", + "valid-url": "^1.0.9" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6120,7 +6254,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -6207,6 +6340,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lezer-json5": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lezer-json5/-/lezer-json5-2.0.2.tgz", + "integrity": "sha512-NRmtBlKW/f8mA7xatKq8IUOq045t8GVHI4kZXrUtYYUdiVeGiO6zKGAV7/nUAnf5q+rYTY+SWX/gvQdFXMjNxQ==", + "dependencies": { + "@lezer/lr": "^1.0.0" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -6590,6 +6731,11 @@ "ufo": "^1.3.0" } }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6654,12 +6800,57 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", @@ -7606,6 +7797,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -8114,6 +8322,14 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "engines": { + "node": ">=0.12" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -8523,6 +8739,17 @@ "node": ">=8.0.0" } }, + "node_modules/smtp-address-parser": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz", + "integrity": "sha512-Osg9LmvGeAG/hyao4mldbflLOkkr3a+h4m1lwKCK5U8M6ZAr7tdXEz/+/vr752TSGE4MNUlUl9cIK2cB8cgzXg==", + "dependencies": { + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9253,6 +9480,11 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-easing": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", @@ -9448,8 +9680,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "devOptional": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unique-string": { "version": "1.0.0", @@ -9578,6 +9809,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -9780,6 +10016,20 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0408d97b..3ff30261 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "classnames": "^2.3.2", "cm6-graphql": "^0.0.9", "codemirror": "^6.0.1", + "codemirror-json-schema": "^0.6.1", + "codemirror-json5": "^1.0.3", "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 92d69ec6..b504ab63 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -84,7 +84,11 @@ async fn grpc_reflect( // app_handle: AppHandle, // db_instance: State<'_, Mutex>>, ) -> Result, String> { - let uri = Uri::from_str(endpoint).map_err(|e| e.to_string())?; + let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + Uri::from_str(endpoint).map_err(|e| e.to_string())? + } else { + Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? + }; Ok(grpc::callable(&uri).await) } @@ -97,7 +101,11 @@ async fn grpc_call_unary( // app_handle: AppHandle, // db_instance: State<'_, Mutex>>, ) -> Result { - let uri = Uri::from_str(endpoint).map_err(|e| e.to_string())?; + let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + Uri::from_str(endpoint).map_err(|e| e.to_string())? + } else { + Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? + }; Ok(grpc::call(&uri, service, method, message).await) } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index d33fd3ce..cac82da4 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -95,6 +95,19 @@ pub struct EnvironmentVariable { pub value: String, } +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct Folder { + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub id: String, + pub workspace_id: String, + pub folder_id: Option, + pub model: String, + pub name: String, + pub sort_priority: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct HttpRequestHeader { @@ -139,19 +152,6 @@ pub struct HttpRequest { pub headers: Json>, } -#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default, rename_all = "camelCase")] -pub struct Folder { - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, - pub id: String, - pub workspace_id: String, - pub folder_id: Option, - pub model: String, - pub name: String, - pub sort_priority: f64, -} - #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct HttpResponseHeader { @@ -190,6 +190,64 @@ impl HttpResponse { } } +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct GrpcEndpoint { + pub id: String, + pub model: String, + pub workspace_id: String, + pub request_id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub endpoint: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct GrpcMessage { + pub created_at: NaiveDateTime, + pub content: String, +} + +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct GrpcConnection { + pub id: String, + pub model: String, + pub workspace_id: String, + pub grpc_endpoint_id: String, + pub request_id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub messages: Json>, +} + +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct GrpcRequest { + pub id: String, + pub model: String, + pub workspace_id: String, + pub grpc_endpoint_id: String, + pub grpc_connection_id: String, + pub request_id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct GrpcResponse { + pub id: String, + pub model: String, + pub workspace_id: String, + pub grpc_endpoint_id: String, + pub grpc_connection_id: String, + pub request_id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct KeyValue { diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index 31e89bb8..6f17dda8 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -72,7 +72,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi const dialog = useDialog(); return ( -
+
-

Variables

+

Variables

({ namespace: 'debug', key: 'grpc_url', defaultValue: '' }); + const message = useKeyValue({ + namespace: 'debug', + key: 'grpc_message', + defaultValue: '', + }); + const [resp, setResp] = useState(''); + const grpc = useGrpc(url.value ?? null); + const handleConnect = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setResp( + await grpc.callUnary.mutateAsync({ + service: 'helloworld.Greeter', + method: 'SayHello', + message: message.value ?? '', + }), + ); + }, + [grpc.callUnary, message.value], + ); + + useEffect(() => { + console.log('REFLECT SCHEMA', grpc.schema); + }, [grpc.schema]); + + if (url.isLoading || url.value == null) { + return null; + } + + return ( + ( + + + + + )} + rightSlot={() => ( + + )} + /> + ); +} diff --git a/src-web/components/GrpcEditor.tsx b/src-web/components/GrpcEditor.tsx new file mode 100644 index 00000000..362f71d6 --- /dev/null +++ b/src-web/components/GrpcEditor.tsx @@ -0,0 +1,40 @@ +import type { EditorView } from 'codemirror'; +import { updateSchema } from 'codemirror-json-schema'; +import { useEffect, useRef } from 'react'; +import { useGrpc } from '../hooks/useGrpc'; +import { tryFormatJson } from '../lib/formatters'; +import type { EditorProps } from './core/Editor'; +import { Editor } from './core/Editor'; + +type Props = Pick< + EditorProps, + 'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey' +> & { + url: string; +}; + +export function GrpcEditor({ url, defaultValue, ...extraEditorProps }: Props) { + const editorViewRef = useRef(null); + const { schema } = useGrpc(url); + + useEffect(() => { + if (editorViewRef.current == null || schema == null) return; + const foo = schema[0].methods[0].schema; + console.log('UPDATE SCHEMA', foo); + updateSchema(editorViewRef.current, JSON.parse(foo)); + }, [schema]); + + return ( +
+ +
+ ); +} diff --git a/src-web/components/HttpRequestLayout.tsx b/src-web/components/HttpRequestLayout.tsx new file mode 100644 index 00000000..64287bed --- /dev/null +++ b/src-web/components/HttpRequestLayout.tsx @@ -0,0 +1,21 @@ +import type { CSSProperties } from 'react'; +import React from 'react'; +import { SplitLayout } from './core/SplitLayout'; +import { RequestPane } from './RequestPane'; +import { ResponsePane } from './ResponsePane'; + +interface Props { + style: CSSProperties; +} + +export function HttpRequestLayout({ style }: Props) { + return ( + ( + + )} + rightSlot={({ style }) => } + /> + ); +} diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 5b6d601e..ae899af4 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -1,9 +1,11 @@ import classNames from 'classnames'; -import type { CSSProperties } from 'react'; +import type { CSSProperties, FormEvent } from 'react'; import { memo, useCallback, useMemo, useState } from 'react'; import { createGlobalState } from 'react-use'; import { useActiveRequest } from '../hooks/useActiveRequest'; +import { useIsResponseLoading } from '../hooks/useIsResponseLoading'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; +import { useSendRequest } from '../hooks/useSendRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { tryFormatJson } from '../lib/formatters'; import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models'; @@ -33,7 +35,7 @@ import { UrlBar } from './UrlBar'; import { UrlParametersEditor } from './UrlParameterEditor'; interface Props { - style?: CSSProperties; + style: CSSProperties; fullHeight: boolean; className?: string; } @@ -178,6 +180,27 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN [updateRequest], ); + const sendRequest = useSendRequest(activeRequest?.id ?? null); + const handleSend = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + await sendRequest.mutateAsync(); + }, + [sendRequest], + ); + + const handleMethodChange = useCallback( + (method: string) => updateRequest.mutate({ method }), + [updateRequest], + ); + const handleUrlChange = useCallback( + (url: string) => updateRequest.mutate({ url }), + [updateRequest], + ); + + const isLoading = useIsResponseLoading(activeRequestId ?? null); + const { updateKey } = useRequestUpdateKey(activeRequestId ?? null); + return (
& { +type Props = Pick & { className?: string; + method: HttpRequest['method'] | null; + placeholder: string; + onSubmit: (e: FormEvent) => void; + onUrlChange: (url: string) => void; + onMethodChange?: (method: string) => void; + isLoading: boolean; + forceUpdateKey: string; }; -export const UrlBar = memo(function UrlBar({ id: requestId, url, method, className }: Props) { +export const UrlBar = memo(function UrlBar({ + forceUpdateKey, + onUrlChange, + url, + method, + placeholder, + className, + onSubmit, + onMethodChange, + isLoading, +}: Props) { const inputRef = useRef(null); - const sendRequest = useSendRequest(requestId); - const updateRequest = useUpdateRequest(requestId); const [isFocused, setIsFocused] = useState(false); - const handleMethodChange = useCallback( - (method: string) => updateRequest.mutate({ method }), - [updateRequest], - ); - const handleUrlChange = useCallback( - (url: string) => updateRequest.mutate({ url }), - [updateRequest], - ); - const loading = useIsResponseLoading(requestId); - const { updateKey } = useRequestUpdateKey(requestId); - - const handleSubmit = useCallback( - async (e: FormEvent) => { - e.preventDefault(); - sendRequest.mutate(); - }, - [sendRequest], - ); useHotKey('urlBar.focus', () => { const head = inputRef.current?.state.doc.length ?? 0; @@ -48,7 +41,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa }); return ( -
+ setIsFocused(true)} onBlur={() => setIsFocused(false)} containerClassName="shadow shadow-gray-100 dark:shadow-gray-50" - onChange={handleUrlChange} + onChange={onUrlChange} defaultValue={url} - placeholder="https://example.com" + placeholder={placeholder} leftSlot={ - + method != null && + onMethodChange != null && ( + + ) } rightSlot={ } diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index 6b6a0e6a..975beb9d 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -8,14 +8,16 @@ import type { } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useWindowSize } from 'react-use'; +import { useActiveRequest } from '../hooks/useActiveRequest'; import { useIsFullscreen } from '../hooks/useIsFullscreen'; import { useOsInfo } from '../hooks/useOsInfo'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarWidth } from '../hooks/useSidebarWidth'; import { Button } from './core/Button'; import { HStack } from './core/Stacks'; +import { GrpcConnectionLayout } from './GrpcConnectionLayout'; +import { HttpRequestLayout } from './HttpRequestLayout'; import { Overlay } from './Overlay'; -import { RequestResponse } from './RequestResponse'; import { ResizeHandle } from './ResizeHandle'; import { Sidebar } from './Sidebar'; import { SidebarActions } from './SidebarActions'; @@ -31,6 +33,7 @@ const WINDOW_FLOATING_SIDEBAR_WIDTH = 600; export default function Workspace() { const { setWidth, width, resetWidth } = useSidebarWidth(); const { hide, show, hidden } = useSidebarHidden(); + const activeRequest = useActiveRequest(); const windowSize = useWindowSize(); const [floating, setFloating] = useState(false); @@ -163,7 +166,11 @@ export default function Workspace() { > - + {activeRequest?.name.includes('gRPC') ? ( + + ) : ( + + )}
); } diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index f540bf55..e67052ac 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -10,7 +10,6 @@ import { json } from '@codemirror/lang-json'; import { xml } from '@codemirror/lang-xml'; import type { LanguageSupport } from '@codemirror/language'; import { - bracketMatching, foldGutter, foldKeymap, HighlightStyle, @@ -32,6 +31,7 @@ import { } from '@codemirror/view'; import { tags as t } from '@lezer/highlight'; import { graphql, graphqlLanguageSupport } from 'cm6-graphql'; +import { jsonSchema } from 'codemirror-json-schema'; import type { Environment, Workspace } from '../../../lib/models'; import type { EditorProps } from './index'; import { text } from './text/extension'; @@ -83,6 +83,7 @@ export const myHighlightStyle = HighlightStyle.define([ // ]); const syntaxExtensions: Record = { + 'application/grpc': jsonSchema() as any, // TODO: Fix this 'application/graphql': graphqlLanguageSupport(), 'application/json': json(), 'application/javascript': javascript(), @@ -119,7 +120,6 @@ export const baseExtensions = [ history(), dropCursor(), drawSelection(), - bracketMatching(), // TODO: Figure out how to debounce showing of autocomplete in a good way // debouncedAutocompletionDisplay({ millis: 1000 }), // autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }), diff --git a/src-web/components/RequestResponse.tsx b/src-web/components/core/SplitLayout.tsx similarity index 74% rename from src-web/components/RequestResponse.tsx rename to src-web/components/core/SplitLayout.tsx index 0d961fbe..bcb57aed 100644 --- a/src-web/components/RequestResponse.tsx +++ b/src-web/components/core/SplitLayout.tsx @@ -1,32 +1,36 @@ import useResizeObserver from '@react-hook/resize-observer'; import classNames from 'classnames'; -import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; -import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useLocalStorage } from 'react-use'; -import { useActiveRequest } from '../hooks/useActiveRequest'; -import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId'; -import { clamp } from '../lib/clamp'; -import { HotKeyList } from './core/HotKeyList'; -import { RequestPane } from './RequestPane'; -import { ResizeHandle } from './ResizeHandle'; -import { ResponsePane } from './ResponsePane'; +import { useActiveRequestId } from '../../hooks/useActiveRequestId'; +import { useActiveWorkspaceId } from '../../hooks/useActiveWorkspaceId'; +import { clamp } from '../../lib/clamp'; +import { ResizeHandle } from '../ResizeHandle'; +import { HotKeyList } from './HotKeyList'; -interface Props { +interface SlotProps { + orientation: 'horizontal' | 'vertical'; style: CSSProperties; } -const rqst = { gridArea: 'rqst' }; -const resp = { gridArea: 'resp' }; -const drag = { gridArea: 'drag' }; +interface Props { + style: CSSProperties; + leftSlot: (props: SlotProps) => ReactNode; + rightSlot: (props: SlotProps) => ReactNode; +} + +const areaL = { gridArea: 'left' }; +const areaR = { gridArea: 'right' }; +const areaD = { gridArea: 'drag' }; const DEFAULT = 0.5; const MIN_WIDTH_PX = 10; const MIN_HEIGHT_PX = 30; const STACK_VERTICAL_WIDTH = 700; -export const RequestResponse = memo(function RequestResponse({ style }: Props) { +export function SplitLayout({ style, leftSlot, rightSlot }: Props) { const containerRef = useRef(null); - const activeRequest = useActiveRequest(); const [vertical, setVertical] = useState(false); const [widthRaw, setWidth] = useLocalStorage(`body_width::${useActiveWorkspaceId()}`); const [heightRaw, setHeight] = useLocalStorage(`body_height::${useActiveWorkspaceId()}`); @@ -46,13 +50,13 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) { ...style, gridTemplate: vertical ? ` - ' ${rqst.gridArea}' minmax(0,${1 - height}fr) - ' ${drag.gridArea}' 0 - ' ${resp.gridArea}' minmax(0,${height}fr) + ' ${areaL.gridArea}' minmax(0,${1 - height}fr) + ' ${areaD.gridArea}' 0 + ' ${areaR.gridArea}' minmax(0,${height}fr) / 1fr ` : ` - ' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr) + ' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr) / ${1 - width}fr 0 ${width}fr `, }), @@ -117,15 +121,16 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) { [width, height, vertical, setHeight, setWidth], ); - if (activeRequest === null) { + const activeRequestId = useActiveRequestId(); + if (activeRequestId === null) { return ; } return (
- + {leftSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })} - + {rightSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
); -}); +} diff --git a/src-web/hooks/useCreateCookieJar.ts b/src-web/hooks/useCreateCookieJar.ts index 9f2c70be..8edb32d2 100644 --- a/src-web/hooks/useCreateCookieJar.ts +++ b/src-web/hooks/useCreateCookieJar.ts @@ -1,17 +1,17 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import { trackEvent } from '../lib/analytics'; -import type { HttpRequest } from '../lib/models'; +import type { CookieJar } from '../lib/models'; import { useActiveWorkspaceId } from './useActiveWorkspaceId'; +import { cookieJarsQueryKey } from './useCookieJars'; import { usePrompt } from './usePrompt'; -import { requestsQueryKey } from './useRequests'; export function useCreateCookieJar() { const workspaceId = useActiveWorkspaceId(); const queryClient = useQueryClient(); const prompt = usePrompt(); - return useMutation({ + return useMutation({ mutationFn: async () => { if (workspaceId === null) { throw new Error("Cannot create cookie jar when there's no active workspace"); @@ -26,10 +26,10 @@ export function useCreateCookieJar() { return invoke('create_cookie_jar', { workspaceId, name }); }, onSettled: () => trackEvent('CookieJar', 'Create'), - onSuccess: async (request) => { - queryClient.setQueryData( - requestsQueryKey({ workspaceId: request.workspaceId }), - (requests) => [...(requests ?? []), request], + onSuccess: async (cookieJar) => { + queryClient.setQueryData( + cookieJarsQueryKey({ workspaceId: cookieJar.workspaceId }), + (items) => [...(items ?? []), cookieJar], ); }, }); diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts new file mode 100644 index 00000000..55006b1f --- /dev/null +++ b/src-web/hooks/useGrpc.ts @@ -0,0 +1,35 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api'; + +export function useGrpc(url: string | null) { + const callUnary = useMutation< + string, + unknown, + { service: string; method: string; message: string } + >({ + mutationKey: ['grpc_call_reflect', url], + mutationFn: async ({ service, method, message }) => { + if (url === null) throw new Error('No URL provided'); + return (await invoke('grpc_call_unary', { + endpoint: url, + service, + method, + message, + })) as string; + }, + }); + + const reflect = useQuery({ + queryKey: ['grpc_reflect', url ?? ''], + queryFn: async () => { + if (url === null) return null; + console.log('GETTING SCHEMA', url); + return (await invoke('grpc_reflect', { endpoint: url })) as string; + }, + }); + + return { + callUnary, + schema: reflect.data, + }; +} diff --git a/src-web/main.tsx b/src-web/main.tsx index f2a7491a..b53972f7 100644 --- a/src-web/main.tsx +++ b/src-web/main.tsx @@ -11,20 +11,6 @@ import { setAppearanceOnDocument } from './lib/theme/window'; import { appWindow } from '@tauri-apps/api/window'; import { type } from '@tauri-apps/api/os'; -try { - const services: any = await invoke('grpc_reflect', { endpoint: 'http://localhost:50051' }); - console.log('SERVICES', services); - const response = await invoke('grpc_call_unary', { - endpoint: 'http://localhost:50051', - service: services[0].name, - method: services[0].methods[0].name, - message: '{"name": "Greg"}', - }); - console.log('RESPONSE', response); -} catch (err) { - console.log('ERROR', err); -} - // Hide decorations here because it doesn't work in Rust for some reason (bug?) const osType = await type(); if (osType !== 'Darwin') { From e5d10bd72b59871073d9ebf6b71225ca569b2634 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 31 Jan 2024 22:13:46 -0800 Subject: [PATCH 03/41] Hacky client streaming done --- package-lock.json | 10 + package.json | 1 + src-tauri/grpc/src/json_schema.rs | 3 +- src-tauri/grpc/src/lib.rs | 119 +++++++++++- src-tauri/src/main.rs | 72 ++++++- src-web/components/GrpcConnectionLayout.tsx | 197 +++++++++++++++++--- src-web/components/GrpcEditor.tsx | 69 ++++++- src-web/components/SettingsDialog.tsx | 33 +++- src-web/components/UrlBar.tsx | 5 +- src-web/components/core/Icon.tsx | 4 + src-web/components/core/InlineCode.tsx | 3 +- src-web/components/core/Select.tsx | 15 +- src-web/hooks/useAlert.ts | 29 ++- src-web/hooks/useGrpc.ts | 56 +++++- src-web/hooks/useHotKey.ts | 49 ++--- 15 files changed, 546 insertions(+), 119 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42b4fa10..02a63d27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "codemirror": "^6.0.1", "codemirror-json-schema": "^0.6.1", "codemirror-json5": "^1.0.3", + "date-fns": "^3.3.1", "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", @@ -3805,6 +3806,15 @@ "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz", "integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==" }, + "node_modules/date-fns": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", + "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index 3ff30261..7c0e80b7 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "codemirror": "^6.0.1", "codemirror-json-schema": "^0.6.1", "codemirror-json5": "^1.0.3", + "date-fns": "^3.3.1", "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", diff --git a/src-tauri/grpc/src/json_schema.rs b/src-tauri/grpc/src/json_schema.rs index 3d859d17..1bad1c02 100644 --- a/src-tauri/grpc/src/json_schema.rs +++ b/src-tauri/grpc/src/json_schema.rs @@ -22,8 +22,7 @@ pub struct JsonSchemaEntry { enum_: Option>, /// Don't allow any other properties in the object - #[serde(skip_serializing_if = "Option::is_none")] - additional_properties: Option, + additional_properties: bool, /// Set all properties to required #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/grpc/src/lib.rs b/src-tauri/grpc/src/lib.rs index 2668fb29..56dcf85a 100644 --- a/src-tauri/grpc/src/lib.rs +++ b/src-tauri/grpc/src/lib.rs @@ -1,8 +1,10 @@ -use prost_reflect::DynamicMessage; +use prost::Message; +use prost_reflect::{DynamicMessage, SerializeOptions}; use serde::{Deserialize, Serialize}; use serde_json::Deserializer; -use tonic::IntoRequest; +use tokio_stream::{Stream, StreamExt}; use tonic::transport::Uri; +use tonic::{IntoRequest, Response, Streaming}; use crate::codec::DynamicCodec; use crate::proto::{fill_pool, method_desc_to_path}; @@ -11,19 +13,32 @@ mod codec; mod json_schema; mod proto; +pub fn serialize_options() -> SerializeOptions { + SerializeOptions::new().skip_default_fields(false) +} + #[derive(Serialize, Deserialize, Debug, Default)] +#[serde(default, rename_all = "camelCase")] pub struct ServiceDefinition { pub name: String, pub methods: Vec, } #[derive(Serialize, Deserialize, Debug, Default)] +#[serde(default, rename_all = "camelCase")] pub struct MethodDefinition { pub name: String, pub schema: String, + pub client_streaming: bool, + pub server_streaming: bool, } -pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> String { +pub async fn unary( + uri: &Uri, + service: &str, + method: &str, + message_json: &str, +) -> Result { let (pool, conn) = fill_pool(uri).await; let service = pool.get_service_by_name(service).unwrap(); @@ -31,7 +46,8 @@ pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> let input_message = method.input(); let mut deserializer = Deserializer::from_str(message_json); - let req_message = DynamicMessage::deserialize(input_message, &mut deserializer).unwrap(); + let req_message = + DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?; deserializer.end().unwrap(); let mut client = tonic::client::Grpc::new(conn); @@ -47,10 +63,99 @@ pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> client.ready().await.unwrap(); let resp = client.unary(req, path, codec).await.unwrap(); + let msg = resp.into_inner(); + let response_json = serde_json::to_string_pretty(&msg).expect("json to string"); + println!("\n---------- RECEIVING ---------------\n{}", response_json,); + + Ok(response_json) +} + +struct ClientStream {} + +impl Stream for ClientStream { + type Item = DynamicMessage; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + println!("poll_next"); + todo!() + } +} + +pub async fn client_streaming( + uri: &Uri, + service: &str, + method: &str, + message_json: &str, +) -> Result { + let (pool, conn) = fill_pool(uri).await; + + let service = pool.get_service_by_name(service).unwrap(); + let method = &service.methods().find(|m| m.name() == method).unwrap(); + let input_message = method.input(); + + let mut deserializer = Deserializer::from_str(message_json); + let req_message = + DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?; + deserializer.end().unwrap(); + + let mut client = tonic::client::Grpc::new(conn); + + println!( + "\n---------- SENDING -----------------\n{}", + serde_json::to_string_pretty(&req_message).expect("json") + ); + + let req = tonic::Request::new(ClientStream {}); + + let path = method_desc_to_path(method); + let codec = DynamicCodec::new(method.clone()); + client.ready().await.unwrap(); + + let resp = client.client_streaming(req, path, codec).await.unwrap(); let response_json = serde_json::to_string_pretty(&resp.into_inner()).expect("json to string"); println!("\n---------- RECEIVING ---------------\n{}", response_json,); - response_json + Ok(response_json) +} + +pub async fn server_streaming( + uri: &Uri, + service: &str, + method: &str, + message_json: &str, +) -> Result>, String> { + let (pool, conn) = fill_pool(uri).await; + + let service = pool.get_service_by_name(service).unwrap(); + let method = &service.methods().find(|m| m.name() == method).unwrap(); + let input_message = method.input(); + + let mut deserializer = Deserializer::from_str(message_json); + let req_message = + DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?; + deserializer.end().unwrap(); + + let mut client = tonic::client::Grpc::new(conn); + + println!( + "\n---------- SENDING -----------------\n{}", + serde_json::to_string_pretty(&req_message).expect("json") + ); + + let req = req_message.into_request(); + let path = method_desc_to_path(method); + let codec = DynamicCodec::new(method.clone()); + client.ready().await.unwrap(); + + let resp = client.server_streaming(req, path, codec).await.unwrap(); + // let response_json = serde_json::to_string_pretty(&resp.into_inner()).expect("json to string"); + // println!("\n---------- RECEIVING ---------------\n{}", response_json,); + + // Ok(response_json) + Ok(resp) } pub async fn callable(uri: &Uri) -> Vec { @@ -60,12 +165,14 @@ pub async fn callable(uri: &Uri) -> Vec { .map(|s| { let mut def = ServiceDefinition { name: s.full_name().to_string(), - ..Default::default() + methods: vec![], }; for method in s.methods() { let input_message = method.input(); def.methods.push(MethodDefinition { name: method.name().to_string(), + server_streaming: method.is_server_streaming(), + client_streaming: method.is_client_streaming(), schema: serde_json::to_string_pretty(&json_schema::message_to_json_schema( &pool, input_message, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b504ab63..fb1e285c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,32 +8,33 @@ extern crate core; #[macro_use] extern crate objc; -use ::http::Uri; use std::collections::HashMap; use std::env::current_dir; -use std::fs::{create_dir_all, read_to_string, File}; +use std::fs::{create_dir_all, File, read_to_string}; use std::process::exit; use std::str::FromStr; +use ::http::Uri; use fern::colors::ColoredLevelConfig; -use grpc::ServiceDefinition; +use futures::StreamExt; use log::{debug, error, info, warn}; use rand::random; use serde::Serialize; use serde_json::{json, Value}; +use sqlx::{Pool, Sqlite, SqlitePool}; use sqlx::migrate::Migrator; use sqlx::types::Json; -use sqlx::{Pool, Sqlite, SqlitePool}; -#[cfg(target_os = "macos")] -use tauri::TitleBarStyle; use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry}; use tauri::{Manager, WindowEvent}; +#[cfg(target_os = "macos")] +use tauri::TitleBarStyle; use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_window_state::{StateFlags, WindowExt}; use tokio::sync::Mutex; use tokio::time::sleep; use window_shadows::set_shadow; +use grpc::ServiceDefinition; use window_ext::TrafficLightWindowExt; use crate::analytics::{AnalyticsAction, AnalyticsResource}; @@ -106,7 +107,59 @@ async fn grpc_call_unary( } else { Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? }; - Ok(grpc::call(&uri, service, method, message).await) + grpc::unary(&uri, service, method, message).await +} + +#[tauri::command] +async fn grpc_client_streaming( + endpoint: &str, + service: &str, + method: &str, + message: &str, + // app_handle: AppHandle, + // db_instance: State<'_, Mutex>>, +) -> Result { + let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + Uri::from_str(endpoint).map_err(|e| e.to_string())? + } else { + Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? + }; + grpc::client_streaming(&uri, service, method, message).await +} + +#[tauri::command] +async fn grpc_server_streaming( + endpoint: &str, + service: &str, + method: &str, + message: &str, + app_handle: AppHandle, + // db_instance: State<'_, Mutex>>, +) -> Result { + let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + Uri::from_str(endpoint).map_err(|e| e.to_string())? + } else { + Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? + }; + + let mut stream = grpc::server_streaming(&uri, service, method, message) + .await + .unwrap() + .into_inner(); + while let Some(item) = stream.next().await { + match item { + Ok(item) => { + let s = serde_json::to_string(&item).unwrap(); + emit_side_effect(&app_handle, "grpc_message", s.clone()); + println!("GOt item: {}", s); + } + Err(e) => { + println!("\terror: {}", e); + } + } + } + + Ok("foo".to_string()) } #[tauri::command] @@ -937,6 +990,9 @@ fn main() { .level_for("reqwest", log::LevelFilter::Info) .level_for("tokio_util", log::LevelFilter::Info) .level_for("cookie_store", log::LevelFilter::Info) + .level_for("h2", log::LevelFilter::Info) + .level_for("tower", log::LevelFilter::Info) + .level_for("tonic", log::LevelFilter::Info) .with_colors(ColoredLevelConfig::default()) .level(log::LevelFilter::Trace) .build(), @@ -1012,6 +1068,8 @@ fn main() { get_settings, get_workspace, grpc_call_unary, + grpc_client_streaming, + grpc_server_streaming, grpc_reflect, import_data, list_cookie_jars, diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index a348111a..eb7785ed 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -1,13 +1,19 @@ -import type { Props } from 'focus-trap-react'; +import classNames from 'classnames'; import type { CSSProperties, FormEvent } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAlert } from '../hooks/useAlert'; import { useGrpc } from '../hooks/useGrpc'; import { useKeyValue } from '../hooks/useKeyValue'; +import { Banner } from './core/Banner'; import { Editor } from './core/Editor'; +import { HotKeyList } from './core/HotKeyList'; +import { Icon } from './core/Icon'; +import { Select } from './core/Select'; import { SplitLayout } from './core/SplitLayout'; -import { VStack } from './core/Stacks'; +import { HStack, VStack } from './core/Stacks'; import { GrpcEditor } from './GrpcEditor'; import { UrlBar } from './UrlBar'; +import { format } from 'date-fns'; interface Props { style: CSSProperties; @@ -15,6 +21,17 @@ interface Props { export function GrpcConnectionLayout({ style }: Props) { const url = useKeyValue({ namespace: 'debug', key: 'grpc_url', defaultValue: '' }); + const alert = useAlert(); + const service = useKeyValue({ + namespace: 'debug', + key: 'grpc_service', + defaultValue: null, + }); + const method = useKeyValue({ + namespace: 'debug', + key: 'grpc_method', + defaultValue: null, + }); const message = useKeyValue({ namespace: 'debug', key: 'grpc_message', @@ -22,23 +39,90 @@ export function GrpcConnectionLayout({ style }: Props) { }); const [resp, setResp] = useState(''); const grpc = useGrpc(url.value ?? null); + + const activeMethod = useMemo(() => { + if (grpc.schema == null) return null; + const s = grpc.schema.find((s) => s.name === service.value); + if (s == null) return null; + return s.methods.find((m) => m.name === method.value); + }, [grpc.schema, method.value, service.value]); + const handleConnect = useCallback( async (e: FormEvent) => { e.preventDefault(); - setResp( - await grpc.callUnary.mutateAsync({ - service: 'helloworld.Greeter', - method: 'SayHello', + if (activeMethod == null) return; + + if (service.value == null || method.value == null) { + alert({ + id: 'grpc-invalid-service-method', + title: 'Error', + body: 'Service or method not selected', + }); + } + if (activeMethod.serverStreaming && !activeMethod.clientStreaming) { + await grpc.serverStreaming.mutateAsync({ + service: service.value ?? 'n/a', + method: method.value ?? 'n/a', message: message.value ?? '', - }), - ); + }); + } else { + setResp( + await grpc.unary.mutateAsync({ + service: service.value ?? 'n/a', + method: method.value ?? 'n/a', + message: message.value ?? '', + }), + ); + } }, - [grpc.callUnary, message.value], + [ + activeMethod, + alert, + grpc.serverStreaming, + grpc.unary, + message.value, + method.value, + service.value, + ], ); useEffect(() => { - console.log('REFLECT SCHEMA', grpc.schema); - }, [grpc.schema]); + if (grpc.schema == null) return; + const s = grpc.schema.find((s) => s.name === service.value); + if (s == null) { + service.set(grpc.schema[0]?.name ?? null); + method.set(grpc.schema[0]?.methods[0]?.name ?? null); + return; + } + + const m = s.methods.find((m) => m.name === method.value); + if (m == null) { + method.set(s.methods[0]?.name ?? null); + return; + } + }, [grpc.schema, method, service]); + + const handleChangeService = useCallback( + (v: string) => { + const [serviceName, methodName] = v.split('/', 2); + if (serviceName == null || methodName == null) throw new Error('Should never happen'); + method.set(methodName); + service.set(serviceName); + }, + [method, service], + ); + + const select = useMemo(() => { + const options = + grpc.schema?.flatMap((s) => + s.methods.map((m) => ({ + label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`, + value: `${s.name}/${m.name}`, + })), + ) ?? []; + const value = `${service.value ?? ''}/${method.value ?? ''}`; + return { value, options }; + }, [grpc.schema, method.value, service.value]); if (url.isLoading || url.value == null) { return null; @@ -49,33 +133,84 @@ export function GrpcConnectionLayout({ style }: Props) { style={style} leftSlot={() => ( - +
+ + { size="sm" value={settings.updateChannel} onChange={(updateChannel) => updateSettings.mutateAsync({ ...settings, updateChannel })} - options={{ - stable: 'Release', - beta: 'Early Bird (Beta)', - }} + options={[ + { + label: 'Release', + value: 'stable', + }, + { + label: 'Early Bird (Beta)', + value: 'beta', + }, + ]} /> diff --git a/src-web/components/UrlBar.tsx b/src-web/components/UrlBar.tsx index 77e4f650..d1ef61a3 100644 --- a/src-web/components/UrlBar.tsx +++ b/src-web/components/UrlBar.tsx @@ -3,6 +3,7 @@ import type { FormEvent } from 'react'; import { memo, useRef, useState } from 'react'; import { useHotKey } from '../hooks/useHotKey'; import type { HttpRequest } from '../lib/models'; +import type { IconProps } from './core/Icon'; import { IconButton } from './core/IconButton'; import { Input } from './core/Input'; import { RequestMethodDropdown } from './RequestMethodDropdown'; @@ -13,6 +14,7 @@ type Props = Pick & { placeholder: string; onSubmit: (e: FormEvent) => void; onUrlChange: (url: string) => void; + submitIcon?: IconProps['icon']; onMethodChange?: (method: string) => void; isLoading: boolean; forceUpdateKey: string; @@ -27,6 +29,7 @@ export const UrlBar = memo(function UrlBar({ className, onSubmit, onMethodChange, + submitIcon = 'sendHorizontal', isLoading, }: Props) { const inputRef = useRef(null); @@ -77,7 +80,7 @@ export const UrlBar = memo(function UrlBar({ title="Send Request" type="submit" className="w-8 mr-0.5 my-0.5" - icon={isLoading ? 'update' : 'sendHorizontal'} + icon={isLoading ? 'update' : submitIcon} spin={isLoading} hotkeyAction="request.send" /> diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 5d310c7c..adbcff3a 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -29,6 +29,7 @@ const icons = { magicWand: lucide.Wand2Icon, moreVertical: lucide.MoreVerticalIcon, pencil: lucide.PencilIcon, + plug: lucide.Plug, plus: lucide.PlusIcon, plusCircle: lucide.PlusCircleIcon, question: lucide.ShieldQuestionIcon, @@ -39,6 +40,9 @@ const icons = { trash: lucide.TrashIcon, update: lucide.RefreshCcwIcon, upload: lucide.UploadIcon, + arrowUpFromDot: lucide.ArrowUpFromDotIcon, + arrowDownToDot: lucide.ArrowDownToDotIcon, + arrowUpDown: lucide.ArrowUpDownIcon, x: lucide.XIcon, empty: (props: HTMLAttributes) => , diff --git a/src-web/components/core/InlineCode.tsx b/src-web/components/core/InlineCode.tsx index 67d3848d..7c5ec681 100644 --- a/src-web/components/core/InlineCode.tsx +++ b/src-web/components/core/InlineCode.tsx @@ -6,7 +6,8 @@ export function InlineCode({ className, ...props }: HTMLAttributes diff --git a/src-web/components/core/Select.tsx b/src-web/components/core/Select.tsx index 4ef42883..8c733063 100644 --- a/src-web/components/core/Select.tsx +++ b/src-web/components/core/Select.tsx @@ -6,10 +6,11 @@ interface Props { labelPosition?: 'top' | 'left'; labelClassName?: string; hideLabel?: boolean; - value: string; - options: Record; + value: T; + options: { label: string; value: T }[]; onChange: (value: T) => void; size?: 'xs' | 'sm' | 'md' | 'lg'; + className?: string; } export function Select({ @@ -21,12 +22,14 @@ export function Select({ value, options, onChange, + className, size = 'md', }: Props) { const id = `input-${name}`; return (
({ style={selectBackgroundStyles} onChange={(e) => onChange(e.target.value as T)} className={classNames( - 'font-mono text-xs border w-full px-2 outline-none bg-transparent', + 'font-mono text-xs border w-full outline-none bg-transparent pl-2 pr-7', 'border-highlight focus:border-focus', size === 'xs' && 'h-xs', size === 'sm' && 'h-sm', @@ -56,8 +59,8 @@ export function Select({ size === 'lg' && 'h-lg', )} > - {Object.entries(options).map(([value, label]) => ( - ))} @@ -68,7 +71,7 @@ export function Select({ const selectBackgroundStyles = { backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, - backgroundPosition: 'right 0.5rem center', + backgroundPosition: 'right 0.3rem center', backgroundRepeat: 'no-repeat', backgroundSize: '1.5em 1.5em', }; diff --git a/src-web/hooks/useAlert.ts b/src-web/hooks/useAlert.ts index 0dbfac2a..2c3b84e0 100644 --- a/src-web/hooks/useAlert.ts +++ b/src-web/hooks/useAlert.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import type { DialogProps } from '../components/core/Dialog'; import { useDialog } from '../components/DialogContext'; import type { AlertProps } from './Alert'; @@ -5,20 +6,16 @@ import { Alert } from './Alert'; export function useAlert() { const dialog = useDialog(); - return ({ - id, - title, - body, - }: { - id: string; - title: DialogProps['title']; - body: AlertProps['body']; - }) => - dialog.show({ - id, - title, - hideX: true, - size: 'sm', - render: ({ hide }) => Alert({ onHide: hide, body }), - }); + return useCallback( + ({ id, title, body }: { id: string; title: DialogProps['title']; body: AlertProps['body'] }) => + dialog.show({ + id, + title, + hideX: true, + size: 'sm', + render: ({ hide }) => Alert({ onHide: hide, body }), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); } diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts index 55006b1f..7b3939de 100644 --- a/src-web/hooks/useGrpc.ts +++ b/src-web/hooks/useGrpc.ts @@ -1,13 +1,30 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; +import { useState } from 'react'; +import { useListenToTauriEvent } from './useListenToTauriEvent'; + +interface ReflectResponseService { + name: string; + methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[]; +} + +interface Message { + message: string; + time: Date; +} export function useGrpc(url: string | null) { - const callUnary = useMutation< - string, - unknown, - { service: string; method: string; message: string } - >({ - mutationKey: ['grpc_call_reflect', url], + const [messages, setMessages] = useState([]); + useListenToTauriEvent( + 'grpc_message', + (event) => { + console.log('GOT MESSAGE', event); + setMessages((prev) => [...prev, { message: event.payload, time: new Date() }]); + }, + [], + ); + const unary = useMutation({ + mutationKey: ['grpc_unary', url], mutationFn: async ({ service, method, message }) => { if (url === null) throw new Error('No URL provided'); return (await invoke('grpc_call_unary', { @@ -19,17 +36,36 @@ export function useGrpc(url: string | null) { }, }); - const reflect = useQuery({ + const serverStreaming = useMutation< + string, + string, + { service: string; method: string; message: string } + >({ + mutationKey: ['grpc_server_streaming', url], + mutationFn: async ({ service, method, message }) => { + if (url === null) throw new Error('No URL provided'); + return (await invoke('grpc_server_streaming', { + endpoint: url, + service, + method, + message, + })) as string; + }, + }); + + const reflect = useQuery({ queryKey: ['grpc_reflect', url ?? ''], queryFn: async () => { - if (url === null) return null; + if (url === null) return []; console.log('GETTING SCHEMA', url); - return (await invoke('grpc_reflect', { endpoint: url })) as string; + return (await invoke('grpc_reflect', { endpoint: url })) as ReflectResponseService[]; }, }); return { - callUnary, + unary, + serverStreaming, schema: reflect.data, + messages, }; } diff --git a/src-web/hooks/useHotKey.ts b/src-web/hooks/useHotKey.ts index 0796729d..e9da3aea 100644 --- a/src-web/hooks/useHotKey.ts +++ b/src-web/hooks/useHotKey.ts @@ -5,44 +5,47 @@ import { debounce } from '../lib/debounce'; import { useOsInfo } from './useOsInfo'; export type HotkeyAction = - | 'request.send' + | 'environmentEditor.toggle' + | 'grpc.send' + | 'hotkeys.showHelp' | 'request.create' | 'request.duplicate' - | 'sidebar.toggle' - | 'sidebar.focus' - | 'urlBar.focus' - | 'environmentEditor.toggle' - | 'hotkeys.showHelp' - | 'requestSwitcher.prev' + | 'request.send' | 'requestSwitcher.next' - | 'settings.show'; + | 'requestSwitcher.prev' + | 'settings.show' + | 'sidebar.focus' + | 'sidebar.toggle' + | 'urlBar.focus'; const hotkeys: Record = { - 'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], + 'environmentEditor.toggle': ['CmdCtrl+Shift+e'], + 'grpc.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], + 'hotkeys.showHelp': ['CmdCtrl+Shift+/'], 'request.create': ['CmdCtrl+n'], 'request.duplicate': ['CmdCtrl+d'], - 'sidebar.toggle': ['CmdCtrl+b'], - 'sidebar.focus': ['CmdCtrl+1'], - 'urlBar.focus': ['CmdCtrl+l'], - 'environmentEditor.toggle': ['CmdCtrl+Shift+e'], - 'hotkeys.showHelp': ['CmdCtrl+Shift+/'], - 'settings.show': ['CmdCtrl+,'], - 'requestSwitcher.prev': ['Control+Tab'], + 'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], 'requestSwitcher.next': ['Control+Shift+Tab'], + 'requestSwitcher.prev': ['Control+Tab'], + 'settings.show': ['CmdCtrl+,'], + 'sidebar.focus': ['CmdCtrl+1'], + 'sidebar.toggle': ['CmdCtrl+b'], + 'urlBar.focus': ['CmdCtrl+l'], }; const hotkeyLabels: Record = { - 'request.send': 'Send Request', + 'environmentEditor.toggle': 'Edit Environments', + 'grpc.send': 'Send Message', + 'hotkeys.showHelp': 'Show Keyboard Shortcuts', 'request.create': 'New Request', 'request.duplicate': 'Duplicate Request', - 'sidebar.toggle': 'Toggle Sidebar', - 'sidebar.focus': 'Focus Sidebar', - 'urlBar.focus': 'Focus URL', - 'environmentEditor.toggle': 'Edit Environments', - 'hotkeys.showHelp': 'Show Keyboard Shortcuts', - 'requestSwitcher.prev': 'Go To Next Request', + 'request.send': 'Send Request', 'requestSwitcher.next': 'Go To Previous Request', + 'requestSwitcher.prev': 'Go To Next Request', 'settings.show': 'Open Settings', + 'sidebar.focus': 'Focus Sidebar', + 'sidebar.toggle': 'Toggle Sidebar', + 'urlBar.focus': 'Focus URL', }; export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[]; From e6af0c6009f82f7e9e9c5325f1e9f321dc7510d3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 31 Jan 2024 22:13:46 -0800 Subject: [PATCH 04/41] Hacky server streaming done --- package-lock.json | 10 + package.json | 1 + src-tauri/grpc/src/json_schema.rs | 3 +- src-tauri/grpc/src/lib.rs | 119 +++++++++++- src-tauri/src/main.rs | 72 ++++++- src-web/components/GrpcConnectionLayout.tsx | 197 +++++++++++++++++--- src-web/components/GrpcEditor.tsx | 69 ++++++- src-web/components/SettingsDialog.tsx | 33 +++- src-web/components/UrlBar.tsx | 5 +- src-web/components/core/Icon.tsx | 4 + src-web/components/core/InlineCode.tsx | 3 +- src-web/components/core/Select.tsx | 15 +- src-web/hooks/useAlert.ts | 29 ++- src-web/hooks/useGrpc.ts | 56 +++++- src-web/hooks/useHotKey.ts | 49 ++--- 15 files changed, 546 insertions(+), 119 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42b4fa10..02a63d27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "codemirror": "^6.0.1", "codemirror-json-schema": "^0.6.1", "codemirror-json5": "^1.0.3", + "date-fns": "^3.3.1", "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", @@ -3805,6 +3806,15 @@ "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz", "integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==" }, + "node_modules/date-fns": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", + "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index 3ff30261..7c0e80b7 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "codemirror": "^6.0.1", "codemirror-json-schema": "^0.6.1", "codemirror-json5": "^1.0.3", + "date-fns": "^3.3.1", "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", diff --git a/src-tauri/grpc/src/json_schema.rs b/src-tauri/grpc/src/json_schema.rs index 3d859d17..1bad1c02 100644 --- a/src-tauri/grpc/src/json_schema.rs +++ b/src-tauri/grpc/src/json_schema.rs @@ -22,8 +22,7 @@ pub struct JsonSchemaEntry { enum_: Option>, /// Don't allow any other properties in the object - #[serde(skip_serializing_if = "Option::is_none")] - additional_properties: Option, + additional_properties: bool, /// Set all properties to required #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/grpc/src/lib.rs b/src-tauri/grpc/src/lib.rs index 2668fb29..56dcf85a 100644 --- a/src-tauri/grpc/src/lib.rs +++ b/src-tauri/grpc/src/lib.rs @@ -1,8 +1,10 @@ -use prost_reflect::DynamicMessage; +use prost::Message; +use prost_reflect::{DynamicMessage, SerializeOptions}; use serde::{Deserialize, Serialize}; use serde_json::Deserializer; -use tonic::IntoRequest; +use tokio_stream::{Stream, StreamExt}; use tonic::transport::Uri; +use tonic::{IntoRequest, Response, Streaming}; use crate::codec::DynamicCodec; use crate::proto::{fill_pool, method_desc_to_path}; @@ -11,19 +13,32 @@ mod codec; mod json_schema; mod proto; +pub fn serialize_options() -> SerializeOptions { + SerializeOptions::new().skip_default_fields(false) +} + #[derive(Serialize, Deserialize, Debug, Default)] +#[serde(default, rename_all = "camelCase")] pub struct ServiceDefinition { pub name: String, pub methods: Vec, } #[derive(Serialize, Deserialize, Debug, Default)] +#[serde(default, rename_all = "camelCase")] pub struct MethodDefinition { pub name: String, pub schema: String, + pub client_streaming: bool, + pub server_streaming: bool, } -pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> String { +pub async fn unary( + uri: &Uri, + service: &str, + method: &str, + message_json: &str, +) -> Result { let (pool, conn) = fill_pool(uri).await; let service = pool.get_service_by_name(service).unwrap(); @@ -31,7 +46,8 @@ pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> let input_message = method.input(); let mut deserializer = Deserializer::from_str(message_json); - let req_message = DynamicMessage::deserialize(input_message, &mut deserializer).unwrap(); + let req_message = + DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?; deserializer.end().unwrap(); let mut client = tonic::client::Grpc::new(conn); @@ -47,10 +63,99 @@ pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> client.ready().await.unwrap(); let resp = client.unary(req, path, codec).await.unwrap(); + let msg = resp.into_inner(); + let response_json = serde_json::to_string_pretty(&msg).expect("json to string"); + println!("\n---------- RECEIVING ---------------\n{}", response_json,); + + Ok(response_json) +} + +struct ClientStream {} + +impl Stream for ClientStream { + type Item = DynamicMessage; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + println!("poll_next"); + todo!() + } +} + +pub async fn client_streaming( + uri: &Uri, + service: &str, + method: &str, + message_json: &str, +) -> Result { + let (pool, conn) = fill_pool(uri).await; + + let service = pool.get_service_by_name(service).unwrap(); + let method = &service.methods().find(|m| m.name() == method).unwrap(); + let input_message = method.input(); + + let mut deserializer = Deserializer::from_str(message_json); + let req_message = + DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?; + deserializer.end().unwrap(); + + let mut client = tonic::client::Grpc::new(conn); + + println!( + "\n---------- SENDING -----------------\n{}", + serde_json::to_string_pretty(&req_message).expect("json") + ); + + let req = tonic::Request::new(ClientStream {}); + + let path = method_desc_to_path(method); + let codec = DynamicCodec::new(method.clone()); + client.ready().await.unwrap(); + + let resp = client.client_streaming(req, path, codec).await.unwrap(); let response_json = serde_json::to_string_pretty(&resp.into_inner()).expect("json to string"); println!("\n---------- RECEIVING ---------------\n{}", response_json,); - response_json + Ok(response_json) +} + +pub async fn server_streaming( + uri: &Uri, + service: &str, + method: &str, + message_json: &str, +) -> Result>, String> { + let (pool, conn) = fill_pool(uri).await; + + let service = pool.get_service_by_name(service).unwrap(); + let method = &service.methods().find(|m| m.name() == method).unwrap(); + let input_message = method.input(); + + let mut deserializer = Deserializer::from_str(message_json); + let req_message = + DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?; + deserializer.end().unwrap(); + + let mut client = tonic::client::Grpc::new(conn); + + println!( + "\n---------- SENDING -----------------\n{}", + serde_json::to_string_pretty(&req_message).expect("json") + ); + + let req = req_message.into_request(); + let path = method_desc_to_path(method); + let codec = DynamicCodec::new(method.clone()); + client.ready().await.unwrap(); + + let resp = client.server_streaming(req, path, codec).await.unwrap(); + // let response_json = serde_json::to_string_pretty(&resp.into_inner()).expect("json to string"); + // println!("\n---------- RECEIVING ---------------\n{}", response_json,); + + // Ok(response_json) + Ok(resp) } pub async fn callable(uri: &Uri) -> Vec { @@ -60,12 +165,14 @@ pub async fn callable(uri: &Uri) -> Vec { .map(|s| { let mut def = ServiceDefinition { name: s.full_name().to_string(), - ..Default::default() + methods: vec![], }; for method in s.methods() { let input_message = method.input(); def.methods.push(MethodDefinition { name: method.name().to_string(), + server_streaming: method.is_server_streaming(), + client_streaming: method.is_client_streaming(), schema: serde_json::to_string_pretty(&json_schema::message_to_json_schema( &pool, input_message, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b504ab63..fb1e285c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,32 +8,33 @@ extern crate core; #[macro_use] extern crate objc; -use ::http::Uri; use std::collections::HashMap; use std::env::current_dir; -use std::fs::{create_dir_all, read_to_string, File}; +use std::fs::{create_dir_all, File, read_to_string}; use std::process::exit; use std::str::FromStr; +use ::http::Uri; use fern::colors::ColoredLevelConfig; -use grpc::ServiceDefinition; +use futures::StreamExt; use log::{debug, error, info, warn}; use rand::random; use serde::Serialize; use serde_json::{json, Value}; +use sqlx::{Pool, Sqlite, SqlitePool}; use sqlx::migrate::Migrator; use sqlx::types::Json; -use sqlx::{Pool, Sqlite, SqlitePool}; -#[cfg(target_os = "macos")] -use tauri::TitleBarStyle; use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry}; use tauri::{Manager, WindowEvent}; +#[cfg(target_os = "macos")] +use tauri::TitleBarStyle; use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_window_state::{StateFlags, WindowExt}; use tokio::sync::Mutex; use tokio::time::sleep; use window_shadows::set_shadow; +use grpc::ServiceDefinition; use window_ext::TrafficLightWindowExt; use crate::analytics::{AnalyticsAction, AnalyticsResource}; @@ -106,7 +107,59 @@ async fn grpc_call_unary( } else { Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? }; - Ok(grpc::call(&uri, service, method, message).await) + grpc::unary(&uri, service, method, message).await +} + +#[tauri::command] +async fn grpc_client_streaming( + endpoint: &str, + service: &str, + method: &str, + message: &str, + // app_handle: AppHandle, + // db_instance: State<'_, Mutex>>, +) -> Result { + let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + Uri::from_str(endpoint).map_err(|e| e.to_string())? + } else { + Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? + }; + grpc::client_streaming(&uri, service, method, message).await +} + +#[tauri::command] +async fn grpc_server_streaming( + endpoint: &str, + service: &str, + method: &str, + message: &str, + app_handle: AppHandle, + // db_instance: State<'_, Mutex>>, +) -> Result { + let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + Uri::from_str(endpoint).map_err(|e| e.to_string())? + } else { + Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())? + }; + + let mut stream = grpc::server_streaming(&uri, service, method, message) + .await + .unwrap() + .into_inner(); + while let Some(item) = stream.next().await { + match item { + Ok(item) => { + let s = serde_json::to_string(&item).unwrap(); + emit_side_effect(&app_handle, "grpc_message", s.clone()); + println!("GOt item: {}", s); + } + Err(e) => { + println!("\terror: {}", e); + } + } + } + + Ok("foo".to_string()) } #[tauri::command] @@ -937,6 +990,9 @@ fn main() { .level_for("reqwest", log::LevelFilter::Info) .level_for("tokio_util", log::LevelFilter::Info) .level_for("cookie_store", log::LevelFilter::Info) + .level_for("h2", log::LevelFilter::Info) + .level_for("tower", log::LevelFilter::Info) + .level_for("tonic", log::LevelFilter::Info) .with_colors(ColoredLevelConfig::default()) .level(log::LevelFilter::Trace) .build(), @@ -1012,6 +1068,8 @@ fn main() { get_settings, get_workspace, grpc_call_unary, + grpc_client_streaming, + grpc_server_streaming, grpc_reflect, import_data, list_cookie_jars, diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index a348111a..eb7785ed 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -1,13 +1,19 @@ -import type { Props } from 'focus-trap-react'; +import classNames from 'classnames'; import type { CSSProperties, FormEvent } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAlert } from '../hooks/useAlert'; import { useGrpc } from '../hooks/useGrpc'; import { useKeyValue } from '../hooks/useKeyValue'; +import { Banner } from './core/Banner'; import { Editor } from './core/Editor'; +import { HotKeyList } from './core/HotKeyList'; +import { Icon } from './core/Icon'; +import { Select } from './core/Select'; import { SplitLayout } from './core/SplitLayout'; -import { VStack } from './core/Stacks'; +import { HStack, VStack } from './core/Stacks'; import { GrpcEditor } from './GrpcEditor'; import { UrlBar } from './UrlBar'; +import { format } from 'date-fns'; interface Props { style: CSSProperties; @@ -15,6 +21,17 @@ interface Props { export function GrpcConnectionLayout({ style }: Props) { const url = useKeyValue({ namespace: 'debug', key: 'grpc_url', defaultValue: '' }); + const alert = useAlert(); + const service = useKeyValue({ + namespace: 'debug', + key: 'grpc_service', + defaultValue: null, + }); + const method = useKeyValue({ + namespace: 'debug', + key: 'grpc_method', + defaultValue: null, + }); const message = useKeyValue({ namespace: 'debug', key: 'grpc_message', @@ -22,23 +39,90 @@ export function GrpcConnectionLayout({ style }: Props) { }); const [resp, setResp] = useState(''); const grpc = useGrpc(url.value ?? null); + + const activeMethod = useMemo(() => { + if (grpc.schema == null) return null; + const s = grpc.schema.find((s) => s.name === service.value); + if (s == null) return null; + return s.methods.find((m) => m.name === method.value); + }, [grpc.schema, method.value, service.value]); + const handleConnect = useCallback( async (e: FormEvent) => { e.preventDefault(); - setResp( - await grpc.callUnary.mutateAsync({ - service: 'helloworld.Greeter', - method: 'SayHello', + if (activeMethod == null) return; + + if (service.value == null || method.value == null) { + alert({ + id: 'grpc-invalid-service-method', + title: 'Error', + body: 'Service or method not selected', + }); + } + if (activeMethod.serverStreaming && !activeMethod.clientStreaming) { + await grpc.serverStreaming.mutateAsync({ + service: service.value ?? 'n/a', + method: method.value ?? 'n/a', message: message.value ?? '', - }), - ); + }); + } else { + setResp( + await grpc.unary.mutateAsync({ + service: service.value ?? 'n/a', + method: method.value ?? 'n/a', + message: message.value ?? '', + }), + ); + } }, - [grpc.callUnary, message.value], + [ + activeMethod, + alert, + grpc.serverStreaming, + grpc.unary, + message.value, + method.value, + service.value, + ], ); useEffect(() => { - console.log('REFLECT SCHEMA', grpc.schema); - }, [grpc.schema]); + if (grpc.schema == null) return; + const s = grpc.schema.find((s) => s.name === service.value); + if (s == null) { + service.set(grpc.schema[0]?.name ?? null); + method.set(grpc.schema[0]?.methods[0]?.name ?? null); + return; + } + + const m = s.methods.find((m) => m.name === method.value); + if (m == null) { + method.set(s.methods[0]?.name ?? null); + return; + } + }, [grpc.schema, method, service]); + + const handleChangeService = useCallback( + (v: string) => { + const [serviceName, methodName] = v.split('/', 2); + if (serviceName == null || methodName == null) throw new Error('Should never happen'); + method.set(methodName); + service.set(serviceName); + }, + [method, service], + ); + + const select = useMemo(() => { + const options = + grpc.schema?.flatMap((s) => + s.methods.map((m) => ({ + label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`, + value: `${s.name}/${m.name}`, + })), + ) ?? []; + const value = `${service.value ?? ''}/${method.value ?? ''}`; + return { value, options }; + }, [grpc.schema, method.value, service.value]); if (url.isLoading || url.value == null) { return null; @@ -49,33 +133,84 @@ export function GrpcConnectionLayout({ style }: Props) { style={style} leftSlot={() => ( - +
+ + { size="sm" value={settings.updateChannel} onChange={(updateChannel) => updateSettings.mutateAsync({ ...settings, updateChannel })} - options={{ - stable: 'Release', - beta: 'Early Bird (Beta)', - }} + options={[ + { + label: 'Release', + value: 'stable', + }, + { + label: 'Early Bird (Beta)', + value: 'beta', + }, + ]} /> diff --git a/src-web/components/UrlBar.tsx b/src-web/components/UrlBar.tsx index 77e4f650..d1ef61a3 100644 --- a/src-web/components/UrlBar.tsx +++ b/src-web/components/UrlBar.tsx @@ -3,6 +3,7 @@ import type { FormEvent } from 'react'; import { memo, useRef, useState } from 'react'; import { useHotKey } from '../hooks/useHotKey'; import type { HttpRequest } from '../lib/models'; +import type { IconProps } from './core/Icon'; import { IconButton } from './core/IconButton'; import { Input } from './core/Input'; import { RequestMethodDropdown } from './RequestMethodDropdown'; @@ -13,6 +14,7 @@ type Props = Pick & { placeholder: string; onSubmit: (e: FormEvent) => void; onUrlChange: (url: string) => void; + submitIcon?: IconProps['icon']; onMethodChange?: (method: string) => void; isLoading: boolean; forceUpdateKey: string; @@ -27,6 +29,7 @@ export const UrlBar = memo(function UrlBar({ className, onSubmit, onMethodChange, + submitIcon = 'sendHorizontal', isLoading, }: Props) { const inputRef = useRef(null); @@ -77,7 +80,7 @@ export const UrlBar = memo(function UrlBar({ title="Send Request" type="submit" className="w-8 mr-0.5 my-0.5" - icon={isLoading ? 'update' : 'sendHorizontal'} + icon={isLoading ? 'update' : submitIcon} spin={isLoading} hotkeyAction="request.send" /> diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 5d310c7c..adbcff3a 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -29,6 +29,7 @@ const icons = { magicWand: lucide.Wand2Icon, moreVertical: lucide.MoreVerticalIcon, pencil: lucide.PencilIcon, + plug: lucide.Plug, plus: lucide.PlusIcon, plusCircle: lucide.PlusCircleIcon, question: lucide.ShieldQuestionIcon, @@ -39,6 +40,9 @@ const icons = { trash: lucide.TrashIcon, update: lucide.RefreshCcwIcon, upload: lucide.UploadIcon, + arrowUpFromDot: lucide.ArrowUpFromDotIcon, + arrowDownToDot: lucide.ArrowDownToDotIcon, + arrowUpDown: lucide.ArrowUpDownIcon, x: lucide.XIcon, empty: (props: HTMLAttributes) => , diff --git a/src-web/components/core/InlineCode.tsx b/src-web/components/core/InlineCode.tsx index 67d3848d..7c5ec681 100644 --- a/src-web/components/core/InlineCode.tsx +++ b/src-web/components/core/InlineCode.tsx @@ -6,7 +6,8 @@ export function InlineCode({ className, ...props }: HTMLAttributes diff --git a/src-web/components/core/Select.tsx b/src-web/components/core/Select.tsx index 4ef42883..8c733063 100644 --- a/src-web/components/core/Select.tsx +++ b/src-web/components/core/Select.tsx @@ -6,10 +6,11 @@ interface Props { labelPosition?: 'top' | 'left'; labelClassName?: string; hideLabel?: boolean; - value: string; - options: Record; + value: T; + options: { label: string; value: T }[]; onChange: (value: T) => void; size?: 'xs' | 'sm' | 'md' | 'lg'; + className?: string; } export function Select({ @@ -21,12 +22,14 @@ export function Select({ value, options, onChange, + className, size = 'md', }: Props) { const id = `input-${name}`; return (
({ style={selectBackgroundStyles} onChange={(e) => onChange(e.target.value as T)} className={classNames( - 'font-mono text-xs border w-full px-2 outline-none bg-transparent', + 'font-mono text-xs border w-full outline-none bg-transparent pl-2 pr-7', 'border-highlight focus:border-focus', size === 'xs' && 'h-xs', size === 'sm' && 'h-sm', @@ -56,8 +59,8 @@ export function Select({ size === 'lg' && 'h-lg', )} > - {Object.entries(options).map(([value, label]) => ( - ))} @@ -68,7 +71,7 @@ export function Select({ const selectBackgroundStyles = { backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, - backgroundPosition: 'right 0.5rem center', + backgroundPosition: 'right 0.3rem center', backgroundRepeat: 'no-repeat', backgroundSize: '1.5em 1.5em', }; diff --git a/src-web/hooks/useAlert.ts b/src-web/hooks/useAlert.ts index 0dbfac2a..2c3b84e0 100644 --- a/src-web/hooks/useAlert.ts +++ b/src-web/hooks/useAlert.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import type { DialogProps } from '../components/core/Dialog'; import { useDialog } from '../components/DialogContext'; import type { AlertProps } from './Alert'; @@ -5,20 +6,16 @@ import { Alert } from './Alert'; export function useAlert() { const dialog = useDialog(); - return ({ - id, - title, - body, - }: { - id: string; - title: DialogProps['title']; - body: AlertProps['body']; - }) => - dialog.show({ - id, - title, - hideX: true, - size: 'sm', - render: ({ hide }) => Alert({ onHide: hide, body }), - }); + return useCallback( + ({ id, title, body }: { id: string; title: DialogProps['title']; body: AlertProps['body'] }) => + dialog.show({ + id, + title, + hideX: true, + size: 'sm', + render: ({ hide }) => Alert({ onHide: hide, body }), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); } diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts index 55006b1f..7b3939de 100644 --- a/src-web/hooks/useGrpc.ts +++ b/src-web/hooks/useGrpc.ts @@ -1,13 +1,30 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; +import { useState } from 'react'; +import { useListenToTauriEvent } from './useListenToTauriEvent'; + +interface ReflectResponseService { + name: string; + methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[]; +} + +interface Message { + message: string; + time: Date; +} export function useGrpc(url: string | null) { - const callUnary = useMutation< - string, - unknown, - { service: string; method: string; message: string } - >({ - mutationKey: ['grpc_call_reflect', url], + const [messages, setMessages] = useState([]); + useListenToTauriEvent( + 'grpc_message', + (event) => { + console.log('GOT MESSAGE', event); + setMessages((prev) => [...prev, { message: event.payload, time: new Date() }]); + }, + [], + ); + const unary = useMutation({ + mutationKey: ['grpc_unary', url], mutationFn: async ({ service, method, message }) => { if (url === null) throw new Error('No URL provided'); return (await invoke('grpc_call_unary', { @@ -19,17 +36,36 @@ export function useGrpc(url: string | null) { }, }); - const reflect = useQuery({ + const serverStreaming = useMutation< + string, + string, + { service: string; method: string; message: string } + >({ + mutationKey: ['grpc_server_streaming', url], + mutationFn: async ({ service, method, message }) => { + if (url === null) throw new Error('No URL provided'); + return (await invoke('grpc_server_streaming', { + endpoint: url, + service, + method, + message, + })) as string; + }, + }); + + const reflect = useQuery({ queryKey: ['grpc_reflect', url ?? ''], queryFn: async () => { - if (url === null) return null; + if (url === null) return []; console.log('GETTING SCHEMA', url); - return (await invoke('grpc_reflect', { endpoint: url })) as string; + return (await invoke('grpc_reflect', { endpoint: url })) as ReflectResponseService[]; }, }); return { - callUnary, + unary, + serverStreaming, schema: reflect.data, + messages, }; } diff --git a/src-web/hooks/useHotKey.ts b/src-web/hooks/useHotKey.ts index 0796729d..e9da3aea 100644 --- a/src-web/hooks/useHotKey.ts +++ b/src-web/hooks/useHotKey.ts @@ -5,44 +5,47 @@ import { debounce } from '../lib/debounce'; import { useOsInfo } from './useOsInfo'; export type HotkeyAction = - | 'request.send' + | 'environmentEditor.toggle' + | 'grpc.send' + | 'hotkeys.showHelp' | 'request.create' | 'request.duplicate' - | 'sidebar.toggle' - | 'sidebar.focus' - | 'urlBar.focus' - | 'environmentEditor.toggle' - | 'hotkeys.showHelp' - | 'requestSwitcher.prev' + | 'request.send' | 'requestSwitcher.next' - | 'settings.show'; + | 'requestSwitcher.prev' + | 'settings.show' + | 'sidebar.focus' + | 'sidebar.toggle' + | 'urlBar.focus'; const hotkeys: Record = { - 'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], + 'environmentEditor.toggle': ['CmdCtrl+Shift+e'], + 'grpc.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], + 'hotkeys.showHelp': ['CmdCtrl+Shift+/'], 'request.create': ['CmdCtrl+n'], 'request.duplicate': ['CmdCtrl+d'], - 'sidebar.toggle': ['CmdCtrl+b'], - 'sidebar.focus': ['CmdCtrl+1'], - 'urlBar.focus': ['CmdCtrl+l'], - 'environmentEditor.toggle': ['CmdCtrl+Shift+e'], - 'hotkeys.showHelp': ['CmdCtrl+Shift+/'], - 'settings.show': ['CmdCtrl+,'], - 'requestSwitcher.prev': ['Control+Tab'], + 'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], 'requestSwitcher.next': ['Control+Shift+Tab'], + 'requestSwitcher.prev': ['Control+Tab'], + 'settings.show': ['CmdCtrl+,'], + 'sidebar.focus': ['CmdCtrl+1'], + 'sidebar.toggle': ['CmdCtrl+b'], + 'urlBar.focus': ['CmdCtrl+l'], }; const hotkeyLabels: Record = { - 'request.send': 'Send Request', + 'environmentEditor.toggle': 'Edit Environments', + 'grpc.send': 'Send Message', + 'hotkeys.showHelp': 'Show Keyboard Shortcuts', 'request.create': 'New Request', 'request.duplicate': 'Duplicate Request', - 'sidebar.toggle': 'Toggle Sidebar', - 'sidebar.focus': 'Focus Sidebar', - 'urlBar.focus': 'Focus URL', - 'environmentEditor.toggle': 'Edit Environments', - 'hotkeys.showHelp': 'Show Keyboard Shortcuts', - 'requestSwitcher.prev': 'Go To Next Request', + 'request.send': 'Send Request', 'requestSwitcher.next': 'Go To Previous Request', + 'requestSwitcher.prev': 'Go To Next Request', 'settings.show': 'Open Settings', + 'sidebar.focus': 'Focus Sidebar', + 'sidebar.toggle': 'Toggle Sidebar', + 'urlBar.focus': 'Focus URL', }; export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[]; From d82d2229d40abc671f1bbdf605e79782476194ba Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 1 Feb 2024 00:16:09 -0800 Subject: [PATCH 05/41] Styled it up a bit --- src-web/components/GrpcConnectionLayout.tsx | 70 +++++++++-- src-web/components/ResponsePane.tsx | 4 + src-web/components/core/Editor/Editor.tsx | 2 +- src-web/components/core/Icon.tsx | 9 +- src-web/components/core/JsonAttributeTree.tsx | 109 ++++++++++++++++++ .../components/responseViewers/JsonViewer.tsx | 25 ++++ src-web/hooks/useGrpc.ts | 21 +++- 7 files changed, 223 insertions(+), 17 deletions(-) create mode 100644 src-web/components/core/JsonAttributeTree.tsx create mode 100644 src-web/components/responseViewers/JsonViewer.tsx diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index eb7785ed..a6e9118b 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -1,19 +1,24 @@ import classNames from 'classnames'; +import { format } from 'date-fns'; +import { m } from 'framer-motion'; import type { CSSProperties, FormEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAlert } from '../hooks/useAlert'; +import type { GrpcMessage } from '../hooks/useGrpc'; import { useGrpc } from '../hooks/useGrpc'; import { useKeyValue } from '../hooks/useKeyValue'; +import { tryFormatJson } from '../lib/formatters'; import { Banner } from './core/Banner'; import { Editor } from './core/Editor'; import { HotKeyList } from './core/HotKeyList'; import { Icon } from './core/Icon'; +import { JsonAttributeTree } from './core/JsonAttributeTree'; import { Select } from './core/Select'; +import { Separator } from './core/Separator'; import { SplitLayout } from './core/SplitLayout'; import { HStack, VStack } from './core/Stacks'; import { GrpcEditor } from './GrpcEditor'; import { UrlBar } from './UrlBar'; -import { format } from 'date-fns'; interface Props { style: CSSProperties; @@ -37,6 +42,7 @@ export function GrpcConnectionLayout({ style }: Props) { key: 'grpc_message', defaultValue: '', }); + const [activeMessage, setActiveMessage] = useState(null); const [resp, setResp] = useState(''); const grpc = useGrpc(url.value ?? null); @@ -180,7 +186,7 @@ export function GrpcConnectionLayout({ style }: Props) { className={classNames( 'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1', 'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight', - 'shadow shadow-gray-100 dark:shadow-gray-0 relative py-1', + 'shadow shadow-gray-100 dark:shadow-gray-0 relative pt-1', )} > {grpc.unary.error ? ( @@ -188,15 +194,57 @@ export function GrpcConnectionLayout({ style }: Props) { {grpc.unary.error} ) : grpc.messages.length > 0 ? ( - - {[...grpc.messages].reverse().map((m, i) => ( - - -
{format(m.time, 'HH:mm:ss')}
-
{m.message}
-
- ))} -
+
+
+ {...grpc.messages.map((m) => ( + { + if (m === activeMessage) setActiveMessage(null); + else setActiveMessage(m); + }} + alignItems="center" + className={classNames( + 'px-2 py-1 font-mono text-xs opacity-70', + m === activeMessage && 'bg-highlight !opacity-100', + )} + > + +
{m.message}
+
{format(m.time, 'HH:mm:ss')}
+
+ ))} +
+
+
+ +
+
+ +
+ {/**/} +
+
) : resp ? ( ) : ( + // ) : contentType?.startsWith('application/json') ? ( + // )} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index f3867c53..7f13640c 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -38,7 +38,7 @@ export interface EditorProps { className?: string; heightMode?: 'auto' | 'full'; contentType?: string | null; - forceUpdateKey?: string; + forceUpdateKey?: string | number; autoFocus?: boolean; autoSelect?: boolean; defaultValue?: string | null; diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index adbcff3a..f5bda426 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -43,6 +43,10 @@ const icons = { arrowUpFromDot: lucide.ArrowUpFromDotIcon, arrowDownToDot: lucide.ArrowDownToDotIcon, arrowUpDown: lucide.ArrowUpDownIcon, + arrowDown: lucide.ArrowDownIcon, + arrowUp: lucide.ArrowUpIcon, + arrowBigDownDash: lucide.ArrowBigDownDashIcon, + arrowBigUpDash: lucide.ArrowBigUpDashIcon, x: lucide.XIcon, empty: (props: HTMLAttributes) => , @@ -51,7 +55,7 @@ const icons = { export interface IconProps { icon: keyof typeof icons; className?: string; - size?: 'xs' | 'sm' | 'md'; + size?: 'xs' | 'sm' | 'md' | 'lg'; spin?: boolean; } @@ -61,7 +65,8 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: I { + attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`; + + const [isExpanded, setIsExpanded] = useState(depth === 0); + const toggleExpanded = () => setIsExpanded((v) => !v); + + const { isExpandable, children, label, labelClassName } = useMemo<{ + isExpandable: boolean; + children: ReactNode; + label?: string; + labelClassName?: string; + }>(() => { + const jsonType = Object.prototype.toString.call(attrValue); + if (jsonType === '[object Object]') { + return { + children: isExpanded + ? Object.keys(attrValue) + .sort((a, b) => a.localeCompare(b)) + .flatMap((k) => ( + + )) + : null, + isExpandable: true, + label: isExpanded ? undefined : `{⋯}`, + labelClassName: 'text-gray-500', + }; + } else if (jsonType === '[object Array]') { + return { + children: isExpanded + ? attrValue.flatMap((v: any, i: number) => ( + + )) + : null, + isExpandable: true, + label: isExpanded ? undefined : `[⋯]`, + labelClassName: 'text-gray-500', + }; + } else { + return { + children: null, + isExpandable: false, + label: jsonType === '[object String]' ? `"${attrValue}"` : `${attrValue}`, + labelClassName: classNames( + jsonType === '[object Boolean]' && 'text-pink-600', + jsonType === '[object Number]' && 'text-blue-600', + jsonType === '[object String]' && 'text-yellow-600', + jsonType === '[object Null]' && 'text-red-600', + ), + }; + } + }, [attrValue, attrKeyJsonPath, isExpanded, depth]); + + return ( +
+
+ {depth === 0 ? null : isExpandable ? ( + + ) : ( + {attrKey}: + )} + {label} +
+ {children &&
{children}
} +
+ ); +}; + +function joinObjectKey(baseKey: string | undefined, key: string): string { + const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``; + + if (baseKey == null) return quotedKey; + else return `${baseKey}.${quotedKey}`; +} + +function joinArrayKey(baseKey: string | undefined, index: number): string { + return `${baseKey ?? ''}[${index}]`; +} diff --git a/src-web/components/responseViewers/JsonViewer.tsx b/src-web/components/responseViewers/JsonViewer.tsx new file mode 100644 index 00000000..62949b3a --- /dev/null +++ b/src-web/components/responseViewers/JsonViewer.tsx @@ -0,0 +1,25 @@ +import classNames from 'classnames'; +import { useResponseBodyText } from '../../hooks/useResponseBodyText'; +import type { HttpResponse } from '../../lib/models'; +import { JsonAttributeTree } from '../core/JsonAttributeTree'; + +interface Props { + response: HttpResponse; + className?: string; +} + +export function JsonViewer({ response, className }: Props) { + const rawBody = useResponseBodyText(response) ?? ''; + let parsed = {}; + try { + parsed = JSON.parse(rawBody); + } catch (e) { + // foo + } + + return ( +
+ +
+ ); +} diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts index 7b3939de..14567b07 100644 --- a/src-web/hooks/useGrpc.ts +++ b/src-web/hooks/useGrpc.ts @@ -8,18 +8,33 @@ interface ReflectResponseService { methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[]; } -interface Message { +export interface GrpcMessage { message: string; time: Date; + isServer: boolean; } export function useGrpc(url: string | null) { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); useListenToTauriEvent( 'grpc_message', (event) => { console.log('GOT MESSAGE', event); - setMessages((prev) => [...prev, { message: event.payload, time: new Date() }]); + setMessages((prev) => [ + ...prev, + { + message: JSON.stringify({ + dummy: 'Yo, this is a dummy message', + another: 'property', + list: [1, 2, 3, 4, 5], + null: null, + bool: true, + }), + time: new Date(), + isServer: false, + }, + { message: event.payload, time: new Date(), isServer: true }, + ]); }, [], ); From 8b0823984b0fd08ee178852ea6dc79c797409ab5 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 1 Feb 2024 00:36:49 -0800 Subject: [PATCH 06/41] Even better styles --- src-web/components/GrpcConnectionLayout.tsx | 36 +++++++++------- src-web/components/ResponsePane.tsx | 4 +- src-web/components/UrlBar.tsx | 24 ++++++----- src-web/components/core/JsonAttributeTree.tsx | 41 ++++++++++++------- src-web/hooks/useGrpc.ts | 15 ++----- 5 files changed, 67 insertions(+), 53 deletions(-) diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index a6e9118b..b8db8d63 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -1,17 +1,16 @@ import classNames from 'classnames'; import { format } from 'date-fns'; -import { m } from 'framer-motion'; import type { CSSProperties, FormEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAlert } from '../hooks/useAlert'; import type { GrpcMessage } from '../hooks/useGrpc'; import { useGrpc } from '../hooks/useGrpc'; import { useKeyValue } from '../hooks/useKeyValue'; -import { tryFormatJson } from '../lib/formatters'; import { Banner } from './core/Banner'; import { Editor } from './core/Editor'; import { HotKeyList } from './core/HotKeyList'; import { Icon } from './core/Icon'; +import { IconButton } from './core/IconButton'; import { JsonAttributeTree } from './core/JsonAttributeTree'; import { Select } from './core/Select'; import { Separator } from './core/Separator'; @@ -144,12 +143,30 @@ export function GrpcConnectionLayout({ style }: Props) { id="foo" url={url.value ?? ''} method={null} + submitIcon={null} forceUpdateKey="to-do" placeholder="localhost:50051" onSubmit={handleConnect} isLoading={grpc.unary.isLoading} onUrlChange={url.set} - submitIcon={ + /> +
-
+
-
+
) : contentType?.match(/csv|tab-separated/) ? ( + ) : contentType?.startsWith('application/json') ? ( + ) : ( - // ) : contentType?.startsWith('application/json') ? ( - // )} diff --git a/src-web/components/UrlBar.tsx b/src-web/components/UrlBar.tsx index d1ef61a3..1f6b6a9b 100644 --- a/src-web/components/UrlBar.tsx +++ b/src-web/components/UrlBar.tsx @@ -14,7 +14,7 @@ type Props = Pick & { placeholder: string; onSubmit: (e: FormEvent) => void; onUrlChange: (url: string) => void; - submitIcon?: IconProps['icon']; + submitIcon?: IconProps['icon'] | null; onMethodChange?: (method: string) => void; isLoading: boolean; forceUpdateKey: string; @@ -74,16 +74,18 @@ export const UrlBar = memo(function UrlBar({ ) } rightSlot={ - + submitIcon !== null && ( + + ) } /> diff --git a/src-web/components/core/JsonAttributeTree.tsx b/src-web/components/core/JsonAttributeTree.tsx index 6d173b8a..4edeed4f 100644 --- a/src-web/components/core/JsonAttributeTree.tsx +++ b/src-web/components/core/JsonAttributeTree.tsx @@ -38,8 +38,8 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa )) : null, isExpandable: true, - label: isExpanded ? undefined : `{⋯}`, - labelClassName: 'text-gray-500', + label: isExpanded ? '{ }' : `{⋯}`, + labelClassName: 'text-gray-600', }; } else if (jsonType === '[object Array]') { return { @@ -54,8 +54,8 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa )) : null, isExpandable: true, - label: isExpanded ? undefined : `[⋯]`, - labelClassName: 'text-gray-500', + label: isExpanded ? '[ ]' : `[⋯]`, + labelClassName: 'text-gray-600', }; } else { return { @@ -72,25 +72,38 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa } }, [attrValue, attrKeyJsonPath, isExpanded, depth]); + const labelEl = ( + + {label} + + ); return ( -
+
- {depth === 0 ? null : isExpandable ? ( - ) : ( - {attrKey}: + <> + + {attrKey}: + + {labelEl} + )} - {label}
{children &&
{children}
}
diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts index 14567b07..6890a55f 100644 --- a/src-web/hooks/useGrpc.ts +++ b/src-web/hooks/useGrpc.ts @@ -19,20 +19,8 @@ export function useGrpc(url: string | null) { useListenToTauriEvent( 'grpc_message', (event) => { - console.log('GOT MESSAGE', event); setMessages((prev) => [ ...prev, - { - message: JSON.stringify({ - dummy: 'Yo, this is a dummy message', - another: 'property', - list: [1, 2, 3, 4, 5], - null: null, - bool: true, - }), - time: new Date(), - isServer: false, - }, { message: event.payload, time: new Date(), isServer: true }, ]); }, @@ -59,6 +47,9 @@ export function useGrpc(url: string | null) { mutationKey: ['grpc_server_streaming', url], mutationFn: async ({ service, method, message }) => { if (url === null) throw new Error('No URL provided'); + setMessages([ + { isServer: false, message: JSON.stringify(JSON.parse(message)), time: new Date() }, + ]); return (await invoke('grpc_server_streaming', { endpoint: url, service, From 8f139f10efad7f6893501faa745f801d597a53d9 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 1 Feb 2024 00:38:57 -0800 Subject: [PATCH 07/41] Revert response JSON tree --- src-web/components/ResponsePane.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index 20a66a1e..1b737725 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -180,9 +180,9 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro ) : contentType?.match(/csv|tab-separated/) ? ( - ) : contentType?.startsWith('application/json') ? ( - ) : ( + // ) : contentType?.startsWith('application/json') ? ( + // )} From be8dd107e376bf147313a6383e27e3d83f867d6f Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 1 Feb 2024 00:48:03 -0800 Subject: [PATCH 08/41] Some minor tweaks --- src-web/components/GrpcConnectionLayout.tsx | 25 +++++++------------ src-web/components/core/CountBadge.tsx | 2 +- src-web/components/core/JsonAttributeTree.tsx | 2 +- src-web/components/core/Stacks.tsx | 3 ++- tailwind.config.cjs | 5 ++-- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index b8db8d63..e8c1c188 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -214,34 +214,27 @@ export function GrpcConnectionLayout({ style }: Props) { }} alignItems="center" className={classNames( - 'px-2 py-1 font-mono text-xs opacity-70', - m === activeMessage && 'bg-highlight !opacity-100', + 'px-2 py-1 font-mono', + m === activeMessage && 'bg-highlight', )} > -
{m.message}
-
{format(m.time, 'HH:mm:ss')}
+
{m.message}
+
+ {format(m.time, 'HH:mm:ss')} +
))}
-
+
-
- +
+
{/* {count} diff --git a/src-web/components/core/JsonAttributeTree.tsx b/src-web/components/core/JsonAttributeTree.tsx index 4edeed4f..e0deacfa 100644 --- a/src-web/components/core/JsonAttributeTree.tsx +++ b/src-web/components/core/JsonAttributeTree.tsx @@ -81,7 +81,7 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa
{isExpandable ? ( -