Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions apps/yaak-client/components/graphql/GraphQLEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { open } from "@tauri-apps/plugin-dialog";
import type { HttpRequest } from "@yaakapp-internal/models";

import { useAtom } from "jotai";
Expand Down Expand Up @@ -32,9 +33,36 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean>
>("graphQLAutoIntrospectDisabled", {});
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
disabled: autoIntrospectDisabled?.[baseRequest.id],
});
const { schema, isLoading, error, refetch, clear, loadFromFile } = useIntrospectGraphQL(
baseRequest,
{
disabled: autoIntrospectDisabled?.[baseRequest.id],
},
);

const handleLoadFromFile = useCallback(async () => {
const selected = await open({
title: "Load GraphQL Schema",
multiple: false,
filters: [
{
name: "GraphQL Schema",
extensions: ["graphql", "graphqls", "gql", "json"],
},
],
});
if (selected == null) return;

const result = await loadFromFile(selected);
if (result.ok) {
// Disable automatic introspection so URL/method edits don't overwrite
// the schema we just loaded from disk. User can re-enable from this menu.
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
[baseRequest.id]: true,
});
}
}, [autoIntrospectDisabled, baseRequest.id, loadFromFile, setAutoIntrospectDisabled]);
const [currentBody, setCurrentBody] = useStateWithDeps<{
query: string;
variables: string | undefined;
Expand Down Expand Up @@ -151,6 +179,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
keepOpenOnSelect: true,
onSelect: refetch,
},
{
label: "Load Schema from File…",
leftSlot: <Icon icon="import" />,
onSelect: handleLoadFromFile,
},
{ type: "separator", label: "Setting" },
{
label: "Automatic Introspection",
Expand Down Expand Up @@ -195,6 +228,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
isDocOpen,
isLoading,
refetch,
handleLoadFromFile,
autoIntrospectDisabled,
baseRequest.id,
setGraphqlDocStateAtomValue,
Expand Down
33 changes: 32 additions & 1 deletion apps/yaak-client/hooks/useIntrospectGraphQL.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql";
import { useCallback, useEffect, useMemo, useState } from "react";
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { getResponseBodyText } from "../lib/responseBody";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
Expand Down Expand Up @@ -99,6 +101,35 @@ export function useIntrospectGraphQL(
await upsertIntrospection(null);
}, [upsertIntrospection]);

const loadFromFile = useCallback(
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
try {
setIsLoading(true);
setError(undefined);

const bytes = await readFile(path);
const fileContent = new TextDecoder().decode(bytes);
const result = tryBuildIntrospectionFromFile(fileContent);

if ("error" in result) {
setError(result.error);
return { ok: false, error: result.error };
}

await upsertIntrospection(result.content);
return { ok: true };
// oxlint-disable-next-line no-explicit-any
} catch (err: any) {
const message = String("message" in err ? err.message : err);
setError(message);
return { ok: false, error: message };
} finally {
setIsLoading(false);
}
},
[upsertIntrospection],
);

useEffect(() => {
if (introspection.data?.content == null || introspection.data.content === "") {
return;
Expand All @@ -112,7 +143,7 @@ export function useIntrospectGraphQL(
}
}, [introspection.data?.content]);

return { schema, isLoading, error, refetch, clear };
return { schema, isLoading, error, refetch, clear, loadFromFile };
}

function useIntrospectionResult(request: HttpRequest) {
Expand Down
85 changes: 85 additions & 0 deletions apps/yaak-client/lib/graphqlSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { buildSchema, introspectionFromSchema } from "graphql";
import { describe, expect, test } from "vite-plus/test";
import { tryBuildIntrospectionFromFile } from "./graphqlSchema";

const sdl = `
type Query {
hello: String!
user(id: ID!): User
}

type User {
id: ID!
name: String
}
`;

const introspection = introspectionFromSchema(buildSchema(sdl));

describe("tryBuildIntrospectionFromFile", () => {
test("accepts introspection JSON wrapped in { data: ... }", () => {
const input = JSON.stringify({ data: introspection });
const result = tryBuildIntrospectionFromFile(input);

expect("schema" in result).toBe(true);
if ("schema" in result) {
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("hello");
// Output content is the normalized, persistable shape.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});

test("accepts bare introspection JSON without a data wrapper", () => {
const input = JSON.stringify(introspection);
const result = tryBuildIntrospectionFromFile(input);

expect("schema" in result).toBe(true);
if ("schema" in result) {
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("user");
// Bare input is wrapped on the way out.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});

test("accepts a GraphQL SDL string", () => {
const result = tryBuildIntrospectionFromFile(sdl);

expect("schema" in result).toBe(true);
if ("schema" in result) {
const fields = result.schema.getQueryType()?.getFields() ?? {};
expect(fields).toHaveProperty("hello");
expect(fields).toHaveProperty("user");
// SDL is converted to introspection JSON for storage.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});

test("returns an error for JSON that is neither introspection nor SDL", () => {
const result = tryBuildIntrospectionFromFile('{"unrelated":"value"}');

expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
}
});

test("returns an error for content that is neither valid JSON nor valid SDL", () => {
const result = tryBuildIntrospectionFromFile("not a schema!@#$");

expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
}
});

test("returns an error when introspection JSON has a malformed __schema", () => {
// Has the data.__schema shape but the contents are invalid for buildClientSchema.
const input = JSON.stringify({ data: { __schema: { broken: true } } });
const result = tryBuildIntrospectionFromFile(input);

expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Failed to build schema from introspection JSON/);
}
});
});
53 changes: 53 additions & 0 deletions apps/yaak-client/lib/graphqlSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";

// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
// { __schema }) or an SDL string and normalizes both into the wrapped
// { data: <introspection> } JSON shape used by the introspection store.
export function tryBuildIntrospectionFromFile(
fileContent: string,
): { schema: GraphQLSchema; content: string } | { error: string } {
let parsedJson: unknown;
try {
parsedJson = JSON.parse(fileContent);
} catch {
parsedJson = undefined;
}

if (parsedJson != null && typeof parsedJson === "object") {
const candidates: unknown[] = [(parsedJson as { data?: unknown }).data, parsedJson];

for (const candidate of candidates) {
if (
candidate != null &&
typeof candidate === "object" &&
"__schema" in (candidate as Record<string, unknown>)
) {
try {
const schema = buildClientSchema(candidate as IntrospectionQuery, {});
return { schema, content: JSON.stringify({ data: candidate }) };
// oxlint-disable-next-line no-explicit-any
} catch (e: any) {
return {
error: `Failed to build schema from introspection JSON: ${String(
"message" in e ? e.message : e,
)}`,
};
}
}
}
}

try {
const schema = buildSchema(fileContent);
const introspection = introspectionFromSchema(schema);
return { schema, content: JSON.stringify({ data: introspection }) };
// oxlint-disable-next-line no-explicit-any
} catch (e: any) {
return {
error: `Could not parse file as introspection JSON or GraphQL SDL: ${String(
"message" in e ? e.message : e,
)}`,
};
}
}