Separate model for GQL introspection data (#222)

This commit is contained in:
Gregory Schier
2025-06-01 06:56:00 -07:00
committed by GitHub
parent f9ac36caf0
commit af230a8f45
18 changed files with 267 additions and 60 deletions

View File

@@ -29,7 +29,7 @@ export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: string }, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonValue }, };
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };

View File

@@ -0,0 +1,21 @@
-- Clean up old key/values that are no longer used
DELETE
FROM key_values
WHERE key LIKE 'graphql_introspection::%';
CREATE TABLE graphql_introspections
(
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'graphql_introspection' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
request_id TEXT NULL
REFERENCES http_requests
ON DELETE CASCADE,
content TEXT NULL
);

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnyModel = CookieJar | Environment | Folder | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
@@ -20,6 +20,8 @@ export type EnvironmentVariable = { enabled?: boolean, name: string, value: stri
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
export type GrpcConnectionState = "initialized" | "connected" | "closed";

View File

@@ -1,9 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
import type { Environment } from "./gen_models";
import type { Folder } from "./gen_models";
import type { GrpcRequest } from "./gen_models";
import type { HttpRequest } from "./gen_models";
import type { WebsocketRequest } from "./gen_models";
import type { Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };

View File

@@ -1,9 +1,11 @@
const COMMANDS: &[&str] = &[
"delete",
"duplicate",
"get_graphql_introspection",
"get_settings",
"grpc_events",
"upsert",
"upsert_graphql_introspection",
"websocket_events",
"workspace_models",
];

View File

@@ -5,6 +5,7 @@ export function newStoreData(): ModelStoreData {
cookie_jar: {},
environment: {},
folder: {},
graphql_introspection: {},
grpc_connection: {},
grpc_event: {},
grpc_request: {},

View File

@@ -4,8 +4,10 @@ permissions = [
"allow-delete",
"allow-duplicate",
"allow-get-settings",
"allow-get-graphql-introspection",
"allow-grpc-events",
"allow-upsert",
"allow-upsert-graphql-introspection",
"allow-websocket-events",
"allow-workspace-models",
]

View File

@@ -1,6 +1,6 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::models::{AnyModel, GrpcEvent, Settings, WebsocketEvent};
use crate::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
use crate::query_manager::QueryManagerExt;
use crate::util::UpdateSource;
use tauri::{AppHandle, Runtime, WebviewWindow};
@@ -90,6 +90,26 @@ pub(crate) fn get_settings<R: Runtime>(app_handle: AppHandle<R>) -> Result<Setti
Ok(app_handle.db().get_settings())
}
#[tauri::command]
pub(crate) fn get_graphql_introspection<R: Runtime>(
app_handle: AppHandle<R>,
request_id: &str,
) -> Result<Option<GraphQlIntrospection>> {
Ok(app_handle.db().get_graphql_introspection(request_id))
}
#[tauri::command]
pub(crate) fn upsert_graphql_introspection<R: Runtime>(
app_handle: AppHandle<R>,
request_id: &str,
workspace_id: &str,
content: Option<String>,
window: WebviewWindow<R>,
) -> Result<GraphQlIntrospection> {
let source = UpdateSource::from_window(&window);
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
}
#[tauri::command]
pub(crate) fn workspace_models<R: Runtime>(
window: WebviewWindow<R>,
@@ -121,11 +141,11 @@ pub(crate) fn workspace_models<R: Runtime>(
}
let j = serde_json::to_string(&l)?;
// NOTE: There's something weird that happens on Linux. If we send Cyrillic (or maybe other)
// unicode characters in this response (doesn't matter where) then the following bug happens:
// https://feedback.yaak.app/p/editing-the-url-sometimes-freezes-the-app
//
//
// It's as if every string resulting from the JSON.parse of the models gets encoded slightly
// wrong or something, causing the above bug where Codemirror can't calculate the cursor
// position anymore (even when none of the characters are included directly in the input).
@@ -137,19 +157,22 @@ pub(crate) fn workspace_models<R: Runtime>(
}
fn escape_str_for_webview(input: &str) -> String {
input.chars().map(|c| {
let code = c as u32;
// ASCII
if code <= 0x7F {
c.to_string()
// BMP characters encoded normally
} else if code < 0xFFFF {
format!("\\u{:04X}", code)
// Beyond BMP encoded a surrogate pairs
} else {
let high = ((code - 0x10000) >> 10) + 0xD800;
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
format!("\\u{:04X}\\u{:04X}", high, low)
}
}).collect()
}
input
.chars()
.map(|c| {
let code = c as u32;
// ASCII
if code <= 0x7F {
c.to_string()
// BMP characters encoded normally
} else if code < 0xFFFF {
format!("\\u{:04X}", code)
// Beyond BMP encoded a surrogate pairs
} else {
let high = ((code - 0x10000) >> 10) + 0xD800;
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
format!("\\u{:04X}\\u{:04X}", high, low)
}
})
.collect()
}

View File

@@ -4,9 +4,9 @@ use crate::util::ModelChangeEvent;
use log::info;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use sqlx::SqlitePool;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::SqlitePool;
use std::fs::create_dir_all;
use std::path::PathBuf;
use std::str::FromStr;
@@ -14,7 +14,7 @@ use std::time::Duration;
use tauri::async_runtime::Mutex;
use tauri::path::BaseDirectory;
use tauri::plugin::TauriPlugin;
use tauri::{generate_handler, AppHandle, Emitter, Manager, Runtime};
use tauri::{AppHandle, Emitter, Manager, Runtime, generate_handler};
use tokio::sync::mpsc;
mod commands;
@@ -39,13 +39,15 @@ impl SqliteConnection {
pub fn init<R: Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("yaak-models")
.invoke_handler(generate_handler![
upsert,
delete,
duplicate,
workspace_models,
grpc_events,
websocket_events,
get_graphql_introspection,
get_settings,
grpc_events,
upsert,
upsert_graphql_introspection,
websocket_events,
workspace_models,
])
.setup(|app_handle, _api| {
let app_path = app_handle.path().app_data_dir().unwrap();

View File

@@ -1342,6 +1342,79 @@ impl UpsertModelInfo for HttpResponse {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "graphql_introspections")]
pub struct GraphQlIntrospection {
#[ts(type = "\"graphql_introspection\"")]
pub model: String,
pub id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub workspace_id: String,
pub request_id: String,
pub content: Option<String>,
}
impl UpsertModelInfo for GraphQlIntrospection {
fn table_name() -> impl IntoTableRef {
GraphQlIntrospectionIden::Table
}
fn id_column() -> impl IntoIden + Eq + Clone {
GraphQlIntrospectionIden::Id
}
fn generate_id() -> String {
generate_prefixed_id("gi")
}
fn order_by() -> (impl IntoColumnRef, Order) {
(GraphQlIntrospectionIden::CreatedAt, Desc)
}
fn get_id(&self) -> String {
self.id.clone()
}
fn insert_values(
self,
source: &UpdateSource,
) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
use GraphQlIntrospectionIden::*;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(WorkspaceId, self.workspace_id.into()),
(RequestId, self.request_id.into()),
(Content, self.content.into()),
])
}
fn update_columns() -> Vec<impl IntoIden> {
vec![
GraphQlIntrospectionIden::UpdatedAt,
GraphQlIntrospectionIden::Content,
]
}
fn from_row(r: &Row) -> rusqlite::Result<Self>
where
Self: Sized,
{
Ok(Self {
id: r.get("id")?,
model: r.get("model")?,
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
workspace_id: r.get("workspace_id")?,
request_id: r.get("request_id")?,
content: r.get("content")?,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
@@ -2002,6 +2075,7 @@ define_any_model! {
CookieJar,
Environment,
Folder,
GraphQlIntrospection,
GrpcConnection,
GrpcEvent,
GrpcRequest,
@@ -2031,6 +2105,9 @@ impl<'de> Deserialize<'de> for AnyModel {
Some(m) if m == "cookie_jar" => AnyModel::CookieJar(fv(value).unwrap()),
Some(m) if m == "environment" => AnyModel::Environment(fv(value).unwrap()),
Some(m) if m == "folder" => AnyModel::Folder(fv(value).unwrap()),
Some(m) if m == "graphql_introspection" => {
AnyModel::GraphQlIntrospection(fv(value).unwrap())
}
Some(m) if m == "grpc_connection" => AnyModel::GrpcConnection(fv(value).unwrap()),
Some(m) if m == "grpc_event" => AnyModel::GrpcEvent(fv(value).unwrap()),
Some(m) if m == "grpc_request" => AnyModel::GrpcRequest(fv(value).unwrap()),

View File

@@ -0,0 +1,55 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
use crate::util::UpdateSource;
use chrono::{Duration, Utc};
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
impl<'a> DbContext<'a> {
pub fn get_graphql_introspection(&self, request_id: &str) -> Option<GraphQlIntrospection> {
self.find_optional(GraphQlIntrospectionIden::RequestId, request_id)
}
pub fn upsert_graphql_introspection(
&self,
workspace_id: &str,
request_id: &str,
content: Option<String>,
source: &UpdateSource,
) -> Result<GraphQlIntrospection> {
// Clean up old ones every time a new one is upserted
self.delete_expired_graphql_introspections()?;
match self.get_graphql_introspection(request_id) {
None => self.upsert(
&GraphQlIntrospection {
content,
request_id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
},
source,
),
Some(introspection) => self.upsert(
&GraphQlIntrospection {
content,
..introspection
},
source,
),
}
}
pub fn delete_expired_graphql_introspections(&self) -> Result<()> {
let cutoff = Utc::now().naive_utc() - Duration::days(7);
let (sql, params) = Query::delete()
.from_table(GraphQlIntrospectionIden::Table)
.cond_where(Expr::col(GraphQlIntrospectionIden::UpdatedAt).lt(cutoff))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
stmt.execute(&*params.as_params())?;
Ok(())
}
}

View File

@@ -2,6 +2,7 @@ mod batch;
mod cookie_jars;
mod environments;
mod folders;
mod graphql_introspections;
mod grpc_connections;
mod grpc_events;
mod grpc_requests;

View File

@@ -29,7 +29,7 @@ export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: string }, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonValue }, };
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };

View File

@@ -117,6 +117,7 @@ impl TryFrom<AnyModel> for SyncModel {
// Non-sync models
AnyModel::CookieJar(m) => return Err(UnknownModel(m.model)),
AnyModel::GraphQlIntrospection(m) => return Err(UnknownModel(m.model)),
AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)),
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)),

View File

@@ -242,7 +242,7 @@ mod parse_and_render_tests {
async fn render_valid_fn() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ say_hello(a='John', b='Kate') ]}"#;
let result = r#"say_hello: 2, Some("John") Some("Kate")"#;
let result = r#"say_hello: 2, Some(String("John")) Some(String("Kate"))"#;
struct CB {}
impl TemplateCallback for CB {
@@ -271,7 +271,7 @@ mod parse_and_render_tests {
async fn render_fn_arg() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ upper(foo='bar') ]}"#;
let result = r#"BAR"#;
let result = r#""BAR""#;
struct CB {}
impl TemplateCallback for CB {
async fn run(
@@ -305,7 +305,7 @@ mod parse_and_render_tests {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo=b64'Zm9vICdiYXInIGJheg') ]}"#;
let result = r#"FOO 'BAR' BAZ"#;
let result = r#""FOO 'BAR' BAZ""#;
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
@@ -334,7 +334,7 @@ mod parse_and_render_tests {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo='${[ foo ]}') ]}"#;
let result = r#"BAR"#;
let result = r#""BAR""#;
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
@@ -364,7 +364,7 @@ mod parse_and_render_tests {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ no_op(inner='${[ foo ]}') ]}"#;
let result = r#"bar"#;
let result = r#""bar""#;
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
@@ -392,7 +392,7 @@ mod parse_and_render_tests {
async fn render_nested_fn() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ upper(foo=secret()) ]}"#;
let result = r#"ABC"#;
let result = r#""ABC""#;
struct CB {}
impl TemplateCallback for CB {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {

View File

@@ -343,7 +343,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
color: 'success',
label: 'Open Workspace Settings',
leftSlot: <Icon icon="settings" />,
onSelect: openWorkspaceSettings,
onSelect: () => openWorkspaceSettings('general'),
},
{ type: 'separator' },
{

View File

@@ -5,7 +5,7 @@ import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { useHotKey } from '../hooks/useHotKey';
import { useKeyboardEvent } from '../hooks/useKeyboardEvent';
import { useRecentRequests } from '../hooks/useRecentRequests';
import {allRequestsAtom} from "../hooks/useAllRequests";
import { allRequestsAtom } from '../hooks/useAllRequests';
import { jotaiStore } from '../lib/jotai';
import { resolvedModelName } from '../lib/resolvedModelName';
import { router } from '../lib/router';

View File

@@ -1,5 +1,7 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import type { GraphQLSchema, IntrospectionQuery } from 'graphql';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import type { GraphQlIntrospection, HttpRequest } from '@yaakapp-internal/models';
import type { GraphQLSchema } from 'graphql';
import { buildClientSchema, getIntrospectionQuery } from 'graphql';
import { useCallback, useEffect, useState } from 'react';
import { minPromiseMillis } from '../lib/minPromiseMillis';
@@ -7,7 +9,6 @@ import { getResponseBodyText } from '../lib/responseBody';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import { useActiveEnvironment } from './useActiveEnvironment';
import { useDebouncedValue } from './useDebouncedValue';
import { useKeyValue } from './useKeyValue';
const introspectionRequestBody = JSON.stringify({
query: getIntrospectionQuery(),
@@ -20,18 +21,38 @@ export function useIntrospectGraphQL(
) {
// Debounce the request because it can change rapidly, and we don't
// want to send so too many requests.
const request = useDebouncedValue(baseRequest);
const debouncedRequest = useDebouncedValue(baseRequest);
const activeEnvironment = useActiveEnvironment();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>();
const [schema, setSchema] = useState<GraphQLSchema | null>(null);
const queryClient = useQueryClient();
const { value: introspection, set: setIntrospection } = useKeyValue<IntrospectionQuery | null>({
key: ['graphql_introspection', baseRequest.id],
fallback: null,
namespace: 'global',
const introspection = useQuery({
queryKey: ['introspection', baseRequest.id],
queryFn: async () =>
invoke<GraphQlIntrospection | null>('plugin:yaak-models|get_graphql_introspection', {
requestId: baseRequest.id,
}),
});
const upsertIntrospection = useCallback(
async (content: string | null) => {
const v = await invoke<GraphQlIntrospection>(
'plugin:yaak-models|upsert_graphql_introspection',
{
requestId: baseRequest.id,
workspaceId: baseRequest.workspaceId,
content: content ?? '',
},
);
// Update local introspection
queryClient.setQueryData(['introspection', baseRequest.id], v);
},
[baseRequest.id, baseRequest.workspaceId, queryClient],
);
const refetch = useCallback(async () => {
try {
setIsLoading(true);
@@ -62,15 +83,14 @@ export function useIntrospectGraphQL(
return setError('Empty body returned in response');
}
const { data } = JSON.parse(bodyText);
console.log(`Got introspection response for ${baseRequest.url}`, data);
await setIntrospection(data);
console.log(`Got introspection response for ${baseRequest.url}`, bodyText);
await upsertIntrospection(bodyText);
} catch (err) {
setError(String(err));
} finally {
setIsLoading(false);
}
}, [activeEnvironment?.id, baseRequest, setIntrospection]);
}, [activeEnvironment?.id, baseRequest, upsertIntrospection]);
useEffect(() => {
// Skip introspection if automatic is disabled and we already have one
@@ -81,27 +101,27 @@ export function useIntrospectGraphQL(
refetch().catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request.id, request.url, request.method, activeEnvironment?.id]);
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
const clear = useCallback(async () => {
setError('');
setSchema(null);
await setIntrospection(null);
}, [setIntrospection]);
await upsertIntrospection(null);
}, [upsertIntrospection]);
useEffect(() => {
if (introspection == null) {
if (introspection.data?.content == null) {
return;
}
try {
const schema = buildClientSchema(introspection);
const schema = buildClientSchema(JSON.parse(introspection.data.content).data);
setSchema(schema);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
setError('message' in e ? e.message : String(e));
}
}, [introspection]);
}, [introspection.data?.content]);
return { schema, isLoading, error, refetch, clear };
}