Proto selection UI/models

This commit is contained in:
Gregory Schier
2024-02-06 12:29:23 -08:00
parent c85a11edf1
commit 562a36d616
28 changed files with 382 additions and 154 deletions

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message\n FROM grpc_requests\n WHERE workspace_id = ?\n ", "query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message,\n proto_files AS \"proto_files!: sqlx::types::Json<Vec<String>>\"\n FROM grpc_requests\n WHERE id = ?\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -62,6 +62,11 @@
"name": "message", "name": "message",
"ordinal": 11, "ordinal": 11,
"type_info": "Text" "type_info": "Text"
},
{
"name": "proto_files!: sqlx::types::Json<Vec<String>>",
"ordinal": 12,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -79,8 +84,9 @@
false, false,
true, true,
true, true,
false,
false false
] ]
}, },
"hash": "35c9607291ee400e7696393bec7f4aa254a2dd163c4f79ea368ac6ee5f74c365" "hash": "7398403d3de2dc5c5b4b6392f083041d9a55194bb97819225a2612fdeb60ad42"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message\n FROM grpc_requests\n WHERE id = ?\n ", "query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message,\n proto_files AS \"proto_files!: sqlx::types::Json<Vec<String>>\"\n FROM grpc_requests\n WHERE workspace_id = ?\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -62,6 +62,11 @@
"name": "message", "name": "message",
"ordinal": 11, "ordinal": 11,
"type_info": "Text" "type_info": "Text"
},
{
"name": "proto_files!: sqlx::types::Json<Vec<String>>",
"ordinal": 12,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -79,8 +84,9 @@
false, false,
true, true,
true, true,
false,
false false
] ]
}, },
"hash": "c5f9b12bca35fe65ae2a1625e7f09c7855ce107046113a45f6da2abcff41819f" "hash": "761d27c3ec425c37ad9abe9c732a9c1746c81ca50d2c413e540b74c8c8e908b7"
} }

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_requests (\n id, name, workspace_id, folder_id, sort_priority, url, service, method, message\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority,\n url = excluded.url,\n service = excluded.service,\n method = excluded.method,\n message = excluded.message\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "d2dc9a652fe08623d70b8b0a2e6823d096e8cbcb9f06544ab245f41425335a25"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_requests (\n id, name, workspace_id, folder_id, sort_priority, url, service, method, message,\n proto_files\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority,\n url = excluded.url,\n service = excluded.service,\n method = excluded.method,\n message = excluded.message,\n proto_files = excluded.proto_files\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "ee562f85ec28c554c607adde670fc30eaeffeed6883e712bda4b4d6ca49cf740"
}

View File

@@ -6,7 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
tonic = "0.10.2" tonic = "0.10.2"
prost = "0.12" prost = "0.12"
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "fs"] }
tonic-reflection = "0.10.2" tonic-reflection = "0.10.2"
tokio-stream = "0.1.14" tokio-stream = "0.1.14"
prost-types = "0.12.3" prost-types = "0.12.3"

View File

@@ -1,8 +1,5 @@
use prost_reflect::SerializeOptions; use prost_reflect::SerializeOptions;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tonic::transport::Uri;
use crate::proto::fill_pool;
mod codec; mod codec;
mod json_schema; mod json_schema;
@@ -28,31 +25,3 @@ pub struct MethodDefinition {
pub client_streaming: bool, pub client_streaming: bool,
pub server_streaming: bool, pub server_streaming: bool,
} }
pub async fn reflect(uri: &Uri) -> Result<Vec<ServiceDefinition>, String> {
let (pool, _) = fill_pool(uri).await?;
Ok(pool
.services()
.map(|s| {
let mut def = ServiceDefinition {
name: s.full_name().to_string(),
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,
))
.unwrap(),
})
}
def
})
.collect::<Vec<_>>())
}

View File

@@ -6,7 +6,6 @@ use hyper_rustls::HttpsConnector;
use prost_reflect::DescriptorPool; use prost_reflect::DescriptorPool;
pub use prost_reflect::DynamicMessage; pub use prost_reflect::DynamicMessage;
use serde_json::Deserializer; use serde_json::Deserializer;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tonic::body::BoxBody; use tonic::body::BoxBody;
@@ -15,6 +14,7 @@ use tonic::{IntoRequest, IntoStreamingRequest, Streaming};
use crate::codec::DynamicCodec; use crate::codec::DynamicCodec;
use crate::proto::{fill_pool, method_desc_to_path}; use crate::proto::{fill_pool, method_desc_to_path};
use crate::{json_schema, MethodDefinition, ServiceDefinition};
type Result<T> = std::result::Result<T, String>; type Result<T> = std::result::Result<T, String>;
@@ -145,25 +145,61 @@ impl GrpcConnection {
} }
} }
pub struct GrpcManager { pub struct GrpcHandle {
connections: HashMap<String, GrpcConnection>, connections: HashMap<String, GrpcConnection>,
pub send: mpsc::Sender<String>, pools: HashMap<String, DescriptorPool>,
pub recv: mpsc::Receiver<String>,
} }
impl Default for GrpcManager { impl Default for GrpcHandle {
fn default() -> Self { fn default() -> Self {
let (send, recv) = mpsc::channel(100);
let connections = HashMap::new(); let connections = HashMap::new();
Self { let pools = HashMap::new();
connections, Self { connections, pools }
send,
recv,
}
} }
} }
impl GrpcManager { impl GrpcHandle {
pub async fn clean_reflect(&mut self, uri: &Uri) -> Result<Vec<ServiceDefinition>> {
self.pools.remove(&uri.to_string());
self.reflect(uri).await
}
pub async fn reflect(&mut self, uri: &Uri) -> Result<Vec<ServiceDefinition>> {
let pool = match self.pools.get(&uri.to_string()) {
Some(p) => p.clone(),
None => {
let (pool, _) = fill_pool(uri).await?;
self.pools.insert(uri.to_string(), pool.clone());
pool
}
};
let result =
pool.services()
.map(|s| {
let mut def = ServiceDefinition {
name: s.full_name().to_string(),
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),
)
.unwrap(),
})
}
def
})
.collect::<Vec<_>>();
Ok(result)
}
pub async fn server_streaming( pub async fn server_streaming(
&mut self, &mut self,
id: &str, id: &str,

View File

@@ -1,4 +1,5 @@
use std::ops::Deref; use std::ops::Deref;
use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use anyhow::anyhow; use anyhow::anyhow;
@@ -9,17 +10,27 @@ use log::warn;
use prost::Message; use prost::Message;
use prost_reflect::{DescriptorPool, MethodDescriptor}; use prost_reflect::{DescriptorPool, MethodDescriptor};
use prost_types::FileDescriptorProto; use prost_types::FileDescriptorProto;
use tokio::fs;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tonic::body::BoxBody; use tonic::body::BoxBody;
use tonic::codegen::http::uri::PathAndQuery; use tonic::codegen::http::uri::PathAndQuery;
use tonic::transport::Uri; use tonic::transport::Uri;
use tonic::Code::Unimplemented;
use tonic::Request; use tonic::Request;
use tonic_reflection::pb::server_reflection_client::ServerReflectionClient; use tonic_reflection::pb::server_reflection_client::ServerReflectionClient;
use tonic_reflection::pb::server_reflection_request::MessageRequest; use tonic_reflection::pb::server_reflection_request::MessageRequest;
use tonic_reflection::pb::server_reflection_response::MessageResponse; use tonic_reflection::pb::server_reflection_response::MessageResponse;
use tonic_reflection::pb::ServerReflectionRequest; use tonic_reflection::pb::ServerReflectionRequest;
pub async fn fill_pool_from_files(paths: Vec<PathBuf>) -> Result<DescriptorPool, String> {
let mut pool = DescriptorPool::new();
for p in paths {
let bytes = fs::read(p).await.unwrap();
let fdp = FileDescriptorProto::decode(bytes.deref()).unwrap();
pool.add_file_descriptor_proto(fdp)
.map_err(|e| e.to_string())?;
}
Ok(pool)
}
pub async fn fill_pool( pub async fn fill_pool(
uri: &Uri, uri: &Uri,
) -> Result< ) -> Result<
@@ -155,7 +166,9 @@ async fn send_reflection_request(
.server_reflection_info(request) .server_reflection_info(request)
.await .await
.map_err(|e| match e.code() { .map_err(|e| match e.code() {
Unimplemented => "Reflection not implemented for server".to_string(), tonic::Code::Unavailable => "Failed to connect to endpoint".to_string(),
tonic::Code::Unauthenticated => "Authentication failed".to_string(),
tonic::Code::DeadlineExceeded => "Deadline exceeded".to_string(),
_ => e.to_string(), _ => e.to_string(),
})? })?
.into_inner() .into_inner()

View File

@@ -0,0 +1 @@
ALTER TABLE grpc_requests ADD COLUMN proto_files TEXT DEFAULT '[]' NOT NULL;

View File

@@ -34,7 +34,7 @@ use tokio::sync::Mutex;
use tokio::time::sleep; use tokio::time::sleep;
use window_shadows::set_shadow; use window_shadows::set_shadow;
use grpc::manager::GrpcManager; use grpc::manager::GrpcHandle;
use grpc::ServiceDefinition; use grpc::ServiceDefinition;
use window_ext::TrafficLightWindowExt; use window_ext::TrafficLightWindowExt;
@@ -95,19 +95,20 @@ async fn migrate_db(app_handle: AppHandle, db: &Mutex<Pool<Sqlite>>) -> Result<(
async fn cmd_grpc_reflect( async fn cmd_grpc_reflect(
request_id: &str, request_id: &str,
app_handle: AppHandle, app_handle: AppHandle,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<Vec<ServiceDefinition>, String> { ) -> Result<Vec<ServiceDefinition>, String> {
let req = get_grpc_request(&app_handle, request_id) let req = get_grpc_request(&app_handle, request_id)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let uri = safe_uri(&req.url).map_err(|e| e.to_string())?; let uri = safe_uri(&req.url).map_err(|e| e.to_string())?;
grpc::reflect(&uri).await grpc_handle.lock().await.clean_reflect(&uri).await
} }
#[tauri::command] #[tauri::command]
async fn cmd_grpc_call_unary( async fn cmd_grpc_call_unary(
request_id: &str, request_id: &str,
app_handle: AppHandle, app_handle: AppHandle,
grpc_handle: State<'_, Mutex<GrpcManager>>, grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<GrpcMessage, String> { ) -> Result<GrpcMessage, String> {
let req = get_grpc_request(&app_handle, request_id) let req = get_grpc_request(&app_handle, request_id)
.await .await
@@ -316,7 +317,7 @@ async fn cmd_grpc_client_streaming(
let conn = conn.clone(); let conn = conn.clone();
let req = req.clone(); let req = req.clone();
async move { async move {
let grpc_handle = app_handle.state::<Mutex<GrpcManager>>(); let grpc_handle = app_handle.state::<Mutex<GrpcHandle>>();
let msg = grpc_handle let msg = grpc_handle
.lock() .lock()
.await .await
@@ -404,7 +405,7 @@ async fn cmd_grpc_client_streaming(
async fn cmd_grpc_streaming( async fn cmd_grpc_streaming(
request_id: &str, request_id: &str,
app_handle: AppHandle, app_handle: AppHandle,
grpc_handle: State<'_, Mutex<GrpcManager>>, grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<String, String> { ) -> Result<String, String> {
let req = get_grpc_request(&app_handle, request_id) let req = get_grpc_request(&app_handle, request_id)
.await .await
@@ -614,7 +615,7 @@ async fn cmd_grpc_streaming(
async fn cmd_grpc_server_streaming( async fn cmd_grpc_server_streaming(
request_id: &str, request_id: &str,
app_handle: AppHandle, app_handle: AppHandle,
grpc_handle: State<'_, Mutex<GrpcManager>>, grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<GrpcConnection, String> { ) -> Result<GrpcConnection, String> {
let req = get_grpc_request(&app_handle, request_id) let req = get_grpc_request(&app_handle, request_id)
.await .await
@@ -1627,7 +1628,7 @@ fn main() {
app.manage(Mutex::new(yaak_updater)); app.manage(Mutex::new(yaak_updater));
// Add GRPC manager // Add GRPC manager
let grpc_handle = GrpcManager::default(); let grpc_handle = GrpcHandle::default();
app.manage(Mutex::new(grpc_handle)); app.manage(Mutex::new(grpc_handle));
// Add DB handle // Add DB handle

View File

@@ -204,6 +204,7 @@ pub struct GrpcRequest {
pub service: Option<String>, pub service: Option<String>,
pub method: Option<String>, pub method: Option<String>,
pub message: String, pub message: String,
pub proto_files: Json<Vec<String>>,
} }
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
@@ -497,9 +498,10 @@ pub async fn upsert_grpc_request(
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO grpc_requests ( INSERT INTO grpc_requests (
id, name, workspace_id, folder_id, sort_priority, url, service, method, message id, name, workspace_id, folder_id, sort_priority, url, service, method, message,
proto_files
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP,
name = excluded.name, name = excluded.name,
@@ -508,7 +510,8 @@ pub async fn upsert_grpc_request(
url = excluded.url, url = excluded.url,
service = excluded.service, service = excluded.service,
method = excluded.method, method = excluded.method,
message = excluded.message message = excluded.message,
proto_files = excluded.proto_files
"#, "#,
id, id,
trimmed_name, trimmed_name,
@@ -519,6 +522,7 @@ pub async fn upsert_grpc_request(
request.service, request.service,
request.method, request.method,
request.message, request.message,
request.proto_files,
) )
.execute(&db) .execute(&db)
.await?; .await?;
@@ -539,7 +543,8 @@ pub async fn get_grpc_request(
r#" r#"
SELECT SELECT
id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,
url, service, method, message url, service, method, message,
proto_files AS "proto_files!: sqlx::types::Json<Vec<String>>"
FROM grpc_requests FROM grpc_requests
WHERE id = ? WHERE id = ?
"#, "#,
@@ -559,7 +564,8 @@ pub async fn list_grpc_requests(
r#" r#"
SELECT SELECT
id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,
url, service, method, message url, service, method, message,
proto_files AS "proto_files!: sqlx::types::Json<Vec<String>>"
FROM grpc_requests FROM grpc_requests
WHERE workspace_id = ? WHERE workspace_id = ?
"#, "#,

View File

@@ -90,7 +90,7 @@ export function GrpcConnectionLayout({ style }: Props) {
onReflectRefetch={grpc.reflect.refetch} onReflectRefetch={grpc.reflect.refetch}
services={services ?? null} services={services ?? null}
reflectionError={grpc.reflect.error as string | undefined} reflectionError={grpc.reflect.error as string | undefined}
reflectionLoading={grpc.reflect.isLoading} reflectionLoading={grpc.reflect.isFetching}
/> />
)} )}
secondSlot={({ style }) => secondSlot={({ style }) =>

View File

@@ -6,9 +6,7 @@ import type { ReflectResponseService } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections'; import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest'; import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import type { GrpcRequest } from '../lib/models'; import type { GrpcRequest } from '../lib/models';
import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { FormattedError } from './core/FormattedError';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { RadioDropdown } from './core/RadioDropdown'; import { RadioDropdown } from './core/RadioDropdown';
@@ -77,6 +75,11 @@ export function GrpcConnectionSetupPane({
[updateRequest], [updateRequest],
); );
const handleSelectProtoFiles = useCallback(
(paths: string[]) => updateRequest.mutateAsync({ protoFiles: paths }),
[updateRequest],
);
const select = useMemo(() => { const select = useMemo(() => {
const options = const options =
services?.flatMap((s) => services?.flatMap((s) =>
@@ -225,17 +228,14 @@ export function GrpcConnectionSetupPane({
</HStack> </HStack>
</div> </div>
<GrpcEditor <GrpcEditor
forceUpdateKey={activeRequest?.id ?? ''}
url={activeRequest.url ?? ''}
defaultValue={activeRequest.message}
onChange={handleChangeMessage} onChange={handleChangeMessage}
service={activeRequest.service}
services={services} services={services}
method={activeRequest.method}
className="bg-gray-50" className="bg-gray-50"
reflectionError={reflectionError} reflectionError={reflectionError}
reflectionLoading={reflectionLoading} reflectionLoading={reflectionLoading}
onReflect={onReflectRefetch} onReflect={onReflectRefetch}
onSelectProtoFiles={handleSelectProtoFiles}
request={activeRequest}
/> />
</VStack> </VStack>
); );

View File

@@ -1,70 +1,72 @@
import { open } from '@tauri-apps/api/dialog';
import type { EditorView } from 'codemirror'; import type { EditorView } from 'codemirror';
import { updateSchema } from 'codemirror-json-schema'; import { updateSchema } from 'codemirror-json-schema';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useAlert } from '../hooks/useAlert'; import { useAlert } from '../hooks/useAlert';
import type { ReflectResponseService } from '../hooks/useGrpc'; import type { ReflectResponseService } from '../hooks/useGrpc';
import { tryFormatJson } from '../lib/formatters'; import { tryFormatJson } from '../lib/formatters';
import type { GrpcRequest } from '../lib/models';
import { count } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { EditorProps } from './core/Editor'; import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor'; import { Editor } from './core/Editor';
import { FormattedError } from './core/FormattedError'; import { FormattedError } from './core/FormattedError';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { useDialog } from './DialogContext'; import { useDialog } from './DialogContext';
import { GrpcProtoSelection } from './GrpcProtoSelection';
type Props = Pick< type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className'> & {
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
url: string;
service: string | null;
method: string | null;
services: ReflectResponseService[] | null; services: ReflectResponseService[] | null;
reflectionError?: string; reflectionError?: string;
reflectionLoading?: boolean; reflectionLoading?: boolean;
request: GrpcRequest;
onReflect: () => void; onReflect: () => void;
onSelectProtoFiles: (paths: string[]) => void;
}; };
export function GrpcEditor({ export function GrpcEditor({
service,
method,
services, services,
defaultValue,
reflectionError, reflectionError,
reflectionLoading, reflectionLoading,
onReflect, onReflect,
onSelectProtoFiles,
request,
...extraEditorProps ...extraEditorProps
}: Props) { }: Props) {
const editorViewRef = useRef<EditorView>(null); const editorViewRef = useRef<EditorView>(null);
const alert = useAlert(); const alert = useAlert();
const dialog = useDialog(); const dialog = useDialog();
// Find the schema for the selected service and method and update the editor
useEffect(() => { useEffect(() => {
if (editorViewRef.current == null || services === null) return; if (editorViewRef.current == null || services === null) return;
const s = services?.find((s) => s.name === service); const s = services.find((s) => s.name === request.service);
if (service != null && s == null) { if (request.service != null && s == null) {
alert({ alert({
id: 'grpc-find-service-error', id: 'grpc-find-service-error',
title: "Couldn't Find Service", title: "Couldn't Find Service",
body: ( body: (
<> <>
Failed to find service <InlineCode>{service}</InlineCode> in schema Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</> </>
), ),
}); });
return; return;
} }
const schema = s?.methods.find((m) => m.name === method)?.schema; const schema = s?.methods.find((m) => m.name === request.method)?.schema;
if (method != null && schema == null) { if (request.method != null && schema == null) {
alert({ alert({
id: 'grpc-find-schema-error', id: 'grpc-find-schema-error',
title: "Couldn't Find Method", title: "Couldn't Find Method",
body: ( body: (
<> <>
Failed to find method <InlineCode>{method}</InlineCode> for{' '} Failed to find method <InlineCode>{request.method}</InlineCode> for{' '}
<InlineCode>{service}</InlineCode> in schema <InlineCode>{request.service}</InlineCode> in schema
</> </>
), ),
}); });
@@ -84,66 +86,98 @@ export function GrpcEditor({
body: ( body: (
<VStack space={4}> <VStack space={4}>
<p> <p>
For service <InlineCode>{service}</InlineCode> and method{' '} For service <InlineCode>{request.service}</InlineCode> and method{' '}
<InlineCode>{method}</InlineCode> <InlineCode>{request.method}</InlineCode>
</p> </p>
<FormattedError>{String(err)}</FormattedError> <FormattedError>{String(err)}</FormattedError>
</VStack> </VStack>
), ),
}); });
console.log('Failed to parse method schema', method, schema);
} }
}, [alert, services, method, service]); }, [alert, services, request.method, request.service]);
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
return ( return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]"> <div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor <Editor
contentType="application/grpc" contentType="application/grpc"
defaultValue={defaultValue} forceUpdateKey={request.id}
defaultValue={request.message}
format={tryFormatJson} format={tryFormatJson}
heightMode="auto" heightMode="auto"
placeholder="..." placeholder="..."
ref={editorViewRef} ref={editorViewRef}
actions={ actions={[
reflectionError || reflectionLoading <div key="reflection" className="!opacity-100">
? [ <Button
<div key="introspection" className="!opacity-100"> size="xs"
<Button color={
key="introspection" reflectionLoading
size="xs" ? 'gray'
color={reflectionError ? 'danger' : 'gray'} : reflectionUnavailable
isLoading={reflectionLoading} ? 'secondary'
onClick={() => { : reflectionError
dialog.show({ ? 'danger'
title: 'Introspection Failed', : 'gray'
size: 'dynamic', }
id: 'introspection-failed', isLoading={reflectionLoading}
render: () => ( onClick={() => {
<> dialog.show({
<FormattedError>{reflectionError ?? 'unknown'}</FormattedError> title: 'Configure Schema',
<HStack className="w-full my-4" space={2} justifyContent="end"> size: 'md',
<Button color="gray">Select .proto</Button> id: 'reflection-failed',
render: ({ hide }) => (
<VStack space={6} className="pb-5">
{reflectionError && <FormattedError>{reflectionError}</FormattedError>}
{reflectionUnavailable && request.protoFiles.length === 0 && (
<Banner>
<VStack space={3}>
<p>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{' '}
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
Server Reflection
</Link>{' '}
. Please manually add the <InlineCode>.proto</InlineCode> files to get
started.
</p>
<div>
<Button <Button
size="xs"
color="gray"
variant="border"
onClick={() => { onClick={() => {
dialog.hide('introspection-failed'); hide();
onReflect(); onReflect();
}} }}
color="secondary"
> >
Try Again Retry Reflection
</Button> </Button>
</HStack> </div>
</> </VStack>
), </Banner>
}); )}
}} <GrpcProtoSelection requestId={request.id} />
> </VStack>
{reflectionError ? 'Reflection Failed' : 'Reflecting'} ),
</Button> });
</div>, }}
] >
: [] {reflectionLoading
} ? 'Inspecting Schema'
: reflectionUnavailable
? 'Select Proto Files'
: reflectionError
? 'Server Error'
: services != null
? 'Proto Schema'
: request.protoFiles.length > 0
? count('Proto File', request.protoFiles.length)
: 'Select Schema'}
</Button>
</div>,
]}
{...extraEditorProps} {...extraEditorProps}
/> />
</div> </div>

View File

@@ -0,0 +1,79 @@
import { open } from '@tauri-apps/api/dialog';
import { useGrpcRequest } from '../hooks/useGrpcRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
export function GrpcProtoSelection({ requestId }: { requestId: string }) {
const request = useGrpcRequest(requestId);
const updateRequest = useUpdateGrpcRequest(request?.id ?? null);
if (request == null) {
return null;
}
return (
<div>
{request.protoFiles.length > 0 && (
<table className="w-full divide-y mb-3">
<thead>
<tr>
<th className="text-gray-600">
<span className="font-mono text-sm">*.proto</span> Files
</th>
<th></th>
</tr>
</thead>
<tbody className="divide-y">
{request.protoFiles.map((f, i) => (
<tr key={f + i} className="group">
<td className="pl-1 text-sm font-mono">{f.split('/').pop()}</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
size="sm"
icon="trash"
className="ml-auto opacity-30 transition-opacity group-hover:opacity-100"
onClick={() => {
updateRequest.mutate({
protoFiles: request.protoFiles.filter((p) => p !== f),
});
}}
/>
</td>
</tr>
))}
</tbody>
</table>
)}
<HStack space={2} justifyContent="end">
<Button
color="gray"
size="sm"
onClick={async () => {
updateRequest.mutate({ protoFiles: [] });
}}
>
Clear Files
</Button>
<Button
color="primary"
size="sm"
onClick={async () => {
const files = await open({
title: 'Select Proto Files',
multiple: true,
filters: [{ name: 'Proto Files', extensions: ['proto'] }],
});
if (files == null || typeof files === 'string') return;
const newFiles = files.filter((f) => !request.protoFiles.includes(f));
updateRequest.mutate({ protoFiles: [...request.protoFiles, ...newFiles] });
}}
>
Add Files
</Button>
</HStack>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import { Simulate } from 'react-dom/test-utils';
import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest'; import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest'; import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
@@ -12,14 +13,18 @@ export const SidebarActions = memo(function SidebarActions() {
const createHttpRequest = useCreateHttpRequest(); const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest(); const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder(); const createFolder = useCreateFolder();
const { hidden, toggle } = useSidebarHidden(); const { hidden, show, hide } = useSidebarHidden();
return ( return (
<HStack> <HStack>
<IconButton <IconButton
onClick={async () => { onClick={async () => {
trackEvent('Sidebar', 'Toggle'); trackEvent('Sidebar', 'Toggle');
await toggle();
// NOTE: We're not using `toggle` because it may be out of sync
// from changes in other windows
if (hidden) await show();
else await hide();
}} }}
className="pointer-events-auto" className="pointer-events-auto"
size="sm" size="sm"

View File

@@ -60,7 +60,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
size === 'md' && 'h-md px-3', size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm', size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm', size === 'xs' && 'h-xs px-2 text-sm',
variant === 'border' && 'border',
// Solids // Solids
variant === 'solid' && color === 'custom' && 'ring-blue-500/50', variant === 'solid' && color === 'custom' && 'ring-blue-500/50',
variant === 'solid' && variant === 'solid' &&
@@ -82,12 +81,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
color === 'danger' && color === 'danger' &&
'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50', 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
// Borders // Borders
variant === 'border' && 'border',
variant === 'border' && variant === 'border' &&
color === 'default' && color === 'default' &&
'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-1000 ring-blue-500/50', 'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'border' && variant === 'border' &&
color === 'gray' && color === 'gray' &&
'border-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50', 'border-gray-500/70 text-gray-700 enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'border' && variant === 'border' &&
color === 'primary' && color === 'primary' &&
'border-blue-500/70 text-blue-700 enabled:hocus:border-blue-500 ring-blue-500/50', 'border-blue-500/70 text-blue-700 enabled:hocus:border-blue-500 ring-blue-500/50',

View File

@@ -6,7 +6,7 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
<code <code
className={classNames( className={classNames(
className, className,
'font-mono text-sm bg-highlight border-0 border-gray-200', 'font-mono text-xs bg-highlight border-0 border-gray-200/30',
'px-1.5 py-0.5 rounded text-gray-800 shadow-inner', 'px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
)} )}
{...props} {...props}

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Icon } from './Icon';
interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string;
}
export function Link({ href, children, className, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//);
className = classNames(className, 'relative underline hover:text-violet-600');
if (isExternal) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={classNames(className, 'pr-4')}
{...other}
>
<span className="underline">{children}</span>
<Icon className="inline absolute right-0.5 top-0.5" size="xs" icon="externalLink" />
</a>
);
}
return (
<RouterLink to={href} className={className} {...other}>
{children}
</RouterLink>
);
}

View File

@@ -1,8 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { emit } from '@tauri-apps/api/event'; import { emit } from '@tauri-apps/api/event';
import { useCallback } from 'react'; import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { GrpcConnection, GrpcMessage, GrpcRequest } from '../lib/models'; import type { GrpcConnection, GrpcMessage, GrpcRequest } from '../lib/models';
import { useDebouncedValue } from './useDebouncedValue';
export interface ReflectResponseService { export interface ReflectResponseService {
name: string; name: string;
@@ -50,11 +51,16 @@ export function useGrpc(req: GrpcRequest | null, conn: GrpcConnection | null) {
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'), mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'),
}); });
const reflect = useQuery<ReflectResponseService[]>({ const debouncedUrl = useDebouncedValue<string>(req?.url ?? 'n/a', 1000);
queryKey: ['grpc_reflect', req?.url ?? 'n/a'], const reflect = useQuery<ReflectResponseService[] | null>({
enabled: req != null && req.protoFiles.length === 0,
queryKey: ['grpc_reflect', debouncedUrl],
queryFn: async () => { queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000)); console.log('REFLECTING...');
return (await invoke('cmd_grpc_reflect', { requestId })) as ReflectResponseService[]; return (await minPromiseMillis(
invoke('cmd_grpc_reflect', { requestId }),
1000,
)) as ReflectResponseService[];
}, },
}); });

View File

@@ -0,0 +1,7 @@
import type { GrpcRequest } from '../lib/models';
import { useGrpcRequests } from './useGrpcRequests';
export function useGrpcRequest(id: string | null): GrpcRequest | null {
const requests = useGrpcRequests();
return requests.find((r) => r.id === id) ?? null;
}

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { useHttpRequests } from './useHttpRequests'; import { useHttpRequests } from './useHttpRequests';
export function useRequest(id: string | null): HttpRequest | null { export function useHttpRequest(id: string | null): HttpRequest | null {
const requests = useHttpRequests(); const requests = useHttpRequests();
return requests.find((r) => r.id === id) ?? null; return requests.find((r) => r.id === id) ?? null;
} }

View File

@@ -28,6 +28,7 @@ export function useKeyValue<T extends Object | null>({
const query = useQuery<T>({ const query = useQuery<T>({
queryKey: keyValueQueryKey({ namespace, key }), queryKey: keyValueQueryKey({ namespace, key }),
queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }), queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }),
refetchOnWindowFocus: false,
}); });
const mutate = useMutation<void, unknown, T>({ const mutate = useMutation<void, unknown, T>({

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { GrpcRequest } from '../lib/models'; import type { GrpcRequest } from '../lib/models';
import { sleep } from '../lib/sleep';
import { getGrpcRequest } from '../lib/store'; import { getGrpcRequest } from '../lib/store';
import { grpcRequestsQueryKey } from './useGrpcRequests'; import { grpcRequestsQueryKey } from './useGrpcRequests';

View File

@@ -3,8 +3,10 @@ import type { GrpcRequest } from '../lib/models';
import { useUpdateAnyGrpcRequest } from './useUpdateAnyGrpcRequest'; import { useUpdateAnyGrpcRequest } from './useUpdateAnyGrpcRequest';
export function useUpdateGrpcRequest(id: string | null) { export function useUpdateGrpcRequest(id: string | null) {
const updateAnyRequest = useUpdateAnyGrpcRequest(); const updateAnyGrpcRequest = useUpdateAnyGrpcRequest();
return useMutation<void, unknown, Partial<GrpcRequest> | ((r: GrpcRequest) => GrpcRequest)>({ return useMutation<void, unknown, Partial<GrpcRequest> | ((r: GrpcRequest) => GrpcRequest)>({
mutationFn: async (update) => updateAnyRequest.mutateAsync({ id: id ?? 'n/a', update }), mutationFn: async (update) => {
return updateAnyGrpcRequest.mutateAsync({ id: id ?? 'n/a', update });
},
}); });
} }

View File

@@ -1,9 +1,19 @@
import { sleep } from './sleep'; import { sleep } from './sleep';
/** Ensures a promise takes at least a certain number of milliseconds to resolve */
export async function minPromiseMillis<T>(promise: Promise<T>, millis: number) { export async function minPromiseMillis<T>(promise: Promise<T>, millis: number) {
const start = Date.now(); const start = Date.now();
const result = await promise; let result;
let err;
try {
result = await promise;
} catch (e) {
err = e;
}
const delayFor = millis - (Date.now() - start); const delayFor = millis - (Date.now() - start);
await sleep(delayFor); await sleep(delayFor);
return result; if (err) throw err;
else return result;
} }

View File

@@ -114,6 +114,7 @@ export interface GrpcRequest extends BaseModel {
service: string | null; service: string | null;
method: string | null; method: string | null;
message: string; message: string;
protoFiles: string[];
} }
export interface GrpcMessage extends BaseModel { export interface GrpcMessage extends BaseModel {

View File

@@ -28,6 +28,15 @@
@apply select-none cursor-default; @apply select-none cursor-default;
} }
a,
a * {
@apply cursor-pointer !important;
}
table th {
@apply text-left;
}
.hide-scrollbars { .hide-scrollbars {
&::-webkit-scrollbar-corner, &::-webkit-scrollbar-corner,
&::-webkit-scrollbar { &::-webkit-scrollbar {