diff --git a/package.json b/package.json
index 4463a37..ad036ab 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,8 @@
"typecheck": "pnpm -r typecheck",
"lint": "eslint --fix packages/*/src",
"knip": "knip",
+ "test": "vitest run",
+ "test:watch": "vitest",
"build": "caido-dev build",
"watch": "caido-dev watch"
},
@@ -20,7 +22,8 @@
"postcss-prefixwrap": "1.51.0",
"tailwindcss": "3.4.13",
"tailwindcss-primeui": "0.3.4",
- "typescript": "5.5.4"
+ "typescript": "5.5.4",
+ "vitest": "4.0.18"
},
"dependencies": {
"@types/d3": "^7.4.3",
diff --git a/packages/backend/src/api/graphql.ts b/packages/backend/src/api/graphql.ts
index 07b407f..76ba2ca 100644
--- a/packages/backend/src/api/graphql.ts
+++ b/packages/backend/src/api/graphql.ts
@@ -1,7 +1,8 @@
import type { SDK } from "caido:plugin";
-import type { GraphQLSchema, Result } from "shared";
+import type { GraphQLSchema, Result, SchemaImportResult } from "shared";
import { GraphQLService } from "../services/graphql";
+import { parseSchemaFromFileContent } from "../services/graphql/schemaImporter";
export async function testGraphQLEndpoint(
sdk: SDK,
@@ -96,3 +97,23 @@ export async function getRequestInfo(
};
}
}
+
+export function importSchemaFromFile(
+ _sdk: SDK,
+ fileContent: string,
+ fileName: string,
+): Result {
+ const result = parseSchemaFromFileContent(fileContent);
+
+ if (result.kind === "Error") {
+ return result;
+ }
+
+ return {
+ kind: "Ok",
+ value: {
+ ...result.value,
+ fileName,
+ },
+ };
+}
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 5ffc09b..f13353c 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -9,6 +9,7 @@ import {
getAttackStatus,
getAttackTemplates,
getRequestInfo,
+ importSchemaFromFile,
startGraphQLAttacks,
testGraphQLEndpoint,
testGraphQLEndpointFromRequest,
@@ -30,6 +31,9 @@ export type {
AttackFinding,
AttackConfig,
AttackSession,
+ DashboardActivity,
+ ExplorerSession,
+ SchemaImportResult,
} from "shared";
export { type BackendEvents } from "./types";
@@ -46,6 +50,7 @@ export type API = DefineAPI<{
getAttackTemplates: typeof getAttackTemplates;
createCaidoFinding: typeof createCaidoFinding;
getRequestInfo: typeof getRequestInfo;
+ importSchemaFromFile: typeof importSchemaFromFile;
}>;
export function init(sdk: SDK) {
@@ -63,6 +68,7 @@ export function init(sdk: SDK) {
sdk.api.register("getAttackTemplates", getAttackTemplates);
sdk.api.register("createCaidoFinding", createCaidoFinding);
sdk.api.register("getRequestInfo", getRequestInfo);
+ sdk.api.register("importSchemaFromFile", importSchemaFromFile);
sdk.console.log("GraphQL Analyzer backend initialized");
}
diff --git a/packages/backend/src/services/graphql/client.ts b/packages/backend/src/services/graphql/client.ts
index 46e0b15..622f98b 100644
--- a/packages/backend/src/services/graphql/client.ts
+++ b/packages/backend/src/services/graphql/client.ts
@@ -1,9 +1,17 @@
import type { SDK } from "caido:plugin";
import { RequestSpec } from "caido:utils";
-import type { GraphQLSchema, IntrospectionSchema, Result } from "shared";
+import type { GraphQLSchema, Result } from "shared";
import { INTROSPECTION_QUERY } from "./introspection";
-import { parseIntrospectionResult } from "./parser";
+import {
+ mapHttpStatusToError,
+ mergeHeaders,
+ parseRawHttpRequest,
+} from "./requestUtils";
+import {
+ parseIntrospectionResponseBody,
+ parseQueryResponseBody,
+} from "./responseParser";
export class GraphQLClient {
constructor(private sdk: SDK) {}
@@ -37,82 +45,11 @@ export class GraphQLClient {
}
const originalRaw = originalRequest.getRaw().toText();
- const lines = originalRaw.split(/\r?\n/);
- const originalHeaders: Record = {};
- let originalBody = "";
+ const parsed = parseRawHttpRequest(originalRaw);
+ const originalHeaders = parsed.headers;
+ const originalBody = parsed.body;
- let inHeaders = false;
- let bodyStartIndex = -1;
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- if (line === undefined) continue;
-
- const trimmedLine = line.trim();
-
- if (i === 0) {
- inHeaders = true;
- continue;
- }
-
- if (inHeaders === true && trimmedLine === "") {
- bodyStartIndex = i + 1;
- break;
- }
-
- if (
- inHeaders === true &&
- typeof trimmedLine === "string" &&
- trimmedLine.includes(":")
- ) {
- const colonIndex = trimmedLine.indexOf(":");
- const headerName = trimmedLine.substring(0, colonIndex).trim();
- const headerValue = trimmedLine.substring(colonIndex + 1).trim();
- if (
- headerName !== "" &&
- headerValue !== "" &&
- headerName.toLowerCase() !== "content-length"
- ) {
- originalHeaders[headerName] = headerValue;
- }
- }
- }
-
- if (bodyStartIndex > 0 && bodyStartIndex < lines.length) {
- originalBody = lines.slice(bodyStartIndex).join("\r\n").trim();
- }
-
- const headers: Record = {
- ...originalHeaders,
- "Content-Type": "application/json",
- Accept: "application/json",
- "User-Agent": "Caido/GraphQL-Analyzer",
- };
-
- if (customHeaders && typeof customHeaders === "object") {
- Object.entries(customHeaders).forEach(([key, value]) => {
- if (
- key &&
- value &&
- typeof key === "string" &&
- typeof value === "string" &&
- key.trim() &&
- value.trim()
- ) {
- headers[key] = String(value);
- }
- });
- }
-
- for (const [key, value] of Object.entries(headers)) {
- if (
- typeof value !== "string" ||
- value === "" ||
- value === "null" ||
- value === "undefined"
- ) {
- delete headers[key];
- }
- }
+ const headers = mergeHeaders(originalHeaders, customHeaders);
const method = originalRequest.getMethod() || "POST";
@@ -207,38 +144,7 @@ export class GraphQLClient {
return { kind: "Error", error: "Failed to extract host from URL" };
}
- const headers: Record = {};
-
- headers["Host"] = String(hostHeader);
- headers["Content-Type"] = "application/json";
- headers["Accept"] = "application/json";
- headers["User-Agent"] = "Caido/GraphQL-Analyzer";
-
- if (customHeaders && typeof customHeaders === "object") {
- Object.entries(customHeaders).forEach(([key, value]) => {
- if (
- key &&
- value &&
- typeof key === "string" &&
- typeof value === "string" &&
- key.trim() &&
- value.trim()
- ) {
- headers[key] = String(value);
- }
- });
- }
-
- for (const [key, value] of Object.entries(headers)) {
- if (
- typeof value !== "string" ||
- value === "" ||
- value === "null" ||
- value === "undefined"
- ) {
- delete headers[key];
- }
- }
+ const headers = mergeHeaders({ Host: String(hostHeader) }, customHeaders);
const requestBody = JSON.stringify({
query: INTROSPECTION_QUERY,
@@ -285,119 +191,12 @@ export class GraphQLClient {
const statusCode = result.response.getCode();
const responseBody = result.response.getBody()?.toText() ?? "";
- if (statusCode === 401) {
- return {
- kind: "Error",
- error: `Authentication required (HTTP 401). Add Authorization, Cookie, or API key headers.`,
- };
- }
-
- if (statusCode === 403) {
- return {
- kind: "Error",
- error: `Access forbidden (HTTP 403). Your credentials lack required permissions.`,
- };
- }
-
- if (statusCode === 404) {
- return {
- kind: "Error",
- error: `Endpoint not found (HTTP 404). Verify the URL is correct.`,
- };
- }
-
- if (statusCode === 405) {
- return {
- kind: "Error",
- error: `Method not allowed (HTTP 405). This endpoint may not support POST requests.`,
- };
+ const statusError = mapHttpStatusToError(statusCode, responseBody);
+ if (statusError !== undefined) {
+ return statusError;
}
- if (statusCode >= 500) {
- return {
- kind: "Error",
- error: `Server error (HTTP ${statusCode}). The server is experiencing issues.`,
- };
- }
-
- if (statusCode !== 200) {
- const trimmed = responseBody.trim();
- if (trimmed.startsWith(" 0
- ) {
- const introspectionDisabled = jsonResponse.errors.some(
- (error: { message?: string }) => {
- const message = error.message;
- return (
- typeof message === "string" &&
- (message.toLowerCase().includes("introspection") ||
- message.toLowerCase().includes("disabled") ||
- message.toLowerCase().includes("not allowed"))
- );
- },
- );
-
- if (introspectionDisabled === true) {
- return { kind: "Ok", value: { supportsIntrospection: false } };
- }
-
- const errorMessages = (
- jsonResponse.errors as Array<{ message?: string }>
- )
- .map((e) => e.message ?? "Unknown error")
- .join(", ");
- return {
- kind: "Error",
- error: `GraphQL error: ${errorMessages}`,
- };
- }
-
- if (
- jsonResponse.data !== undefined &&
- jsonResponse.data.__schema !== undefined
- ) {
- const schema = parseIntrospectionResult(
- jsonResponse.data.__schema as IntrospectionSchema,
- );
- (
- schema as GraphQLSchema & { rawIntrospection?: unknown }
- ).rawIntrospection = jsonResponse.data;
- return { kind: "Ok", value: { supportsIntrospection: true, schema } };
- }
-
- if (jsonResponse.data !== undefined) {
- return {
- kind: "Error",
- error:
- "GraphQL endpoint responded but introspection is disabled or not available.",
- };
- }
-
- return {
- kind: "Error",
- error: "Endpoint returned JSON but it's not a valid GraphQL response.",
- };
- } catch {
- const preview = responseBody.substring(0, 100);
- return { kind: "Error", error: `Invalid JSON response: ${preview}...` };
- }
+ return parseIntrospectionResponseBody(responseBody);
}
async executeQuery(
@@ -432,15 +231,7 @@ export class GraphQLClient {
}
const body = result.response.getBody()?.toText() ?? "";
- try {
- const json = JSON.parse(body);
- return { kind: "Ok", value: json };
- } catch {
- return {
- kind: "Error",
- error: "Invalid JSON response",
- };
- }
+ return parseQueryResponseBody(body);
} catch (error) {
return {
kind: "Error",
diff --git a/packages/backend/src/services/graphql/requestUtils.test.ts b/packages/backend/src/services/graphql/requestUtils.test.ts
new file mode 100644
index 0000000..1206d1c
--- /dev/null
+++ b/packages/backend/src/services/graphql/requestUtils.test.ts
@@ -0,0 +1,209 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ mapHttpStatusToError,
+ mergeHeaders,
+ parseRawHttpRequest,
+} from "./requestUtils";
+
+describe("parseRawHttpRequest", () => {
+ it("parses a standard POST request with headers and body", () => {
+ const raw = [
+ "POST /graphql HTTP/1.1",
+ "Host: example.com",
+ "Content-Type: application/json",
+ "Authorization: Bearer token123",
+ "Content-Length: 42",
+ "",
+ '{"query":"{ __typename }"}',
+ ].join("\r\n");
+
+ const result = parseRawHttpRequest(raw);
+
+ expect(result.headers["Host"]).toBe("example.com");
+ expect(result.headers["Content-Type"]).toBe("application/json");
+ expect(result.headers["Authorization"]).toBe("Bearer token123");
+ expect(result.headers["Content-Length"]).toBeUndefined();
+ expect(result.body).toBe('{"query":"{ __typename }"}');
+ });
+
+ it("parses request with LF line endings", () => {
+ const raw = [
+ "POST /graphql HTTP/1.1",
+ "Host: example.com",
+ "Cookie: session=abc",
+ "",
+ '{"query":"{ users { id } }"}',
+ ].join("\n");
+
+ const result = parseRawHttpRequest(raw);
+ expect(result.headers["Host"]).toBe("example.com");
+ expect(result.headers["Cookie"]).toBe("session=abc");
+ expect(result.body).toBe('{"query":"{ users { id } }"}');
+ });
+
+ it("returns empty body for request without body", () => {
+ const raw = ["GET /graphql HTTP/1.1", "Host: example.com", ""].join("\r\n");
+
+ const result = parseRawHttpRequest(raw);
+ expect(result.headers["Host"]).toBe("example.com");
+ expect(result.body).toBe("");
+ });
+
+ it("handles headers with colons in value", () => {
+ const raw = [
+ "POST /graphql HTTP/1.1",
+ "Host: example.com:8080",
+ "Authorization: Basic dXNlcjpwYXNz",
+ "",
+ "{}",
+ ].join("\r\n");
+
+ const result = parseRawHttpRequest(raw);
+ expect(result.headers["Host"]).toBe("example.com:8080");
+ expect(result.headers["Authorization"]).toBe("Basic dXNlcjpwYXNz");
+ });
+
+ it("handles multiline body", () => {
+ const body = JSON.stringify(
+ { query: "{ users { id name email } }" },
+ undefined,
+ 2,
+ );
+ const raw = [
+ "POST /graphql HTTP/1.1",
+ "Host: example.com",
+ "Content-Type: application/json",
+ "",
+ body,
+ ].join("\r\n");
+
+ const result = parseRawHttpRequest(raw);
+ expect(JSON.parse(result.body)).toEqual({
+ query: "{ users { id name email } }",
+ });
+ });
+
+ it("handles empty raw input", () => {
+ const result = parseRawHttpRequest("");
+ expect(result.headers).toEqual({});
+ expect(result.body).toBe("");
+ });
+});
+
+describe("mergeHeaders", () => {
+ it("adds default headers to original headers", () => {
+ const result = mergeHeaders({ Host: "example.com" });
+ expect(result["Host"]).toBe("example.com");
+ expect(result["Content-Type"]).toBe("application/json");
+ expect(result["Accept"]).toBe("application/json");
+ expect(result["User-Agent"]).toBe("Caido/GraphQL-Analyzer");
+ });
+
+ it("custom headers override defaults", () => {
+ const result = mergeHeaders(
+ { Host: "example.com" },
+ { Authorization: "Bearer token", "User-Agent": "Custom-Agent" },
+ );
+ expect(result["Authorization"]).toBe("Bearer token");
+ expect(result["User-Agent"]).toBe("Custom-Agent");
+ });
+
+ it("filters out null/undefined/empty string values", () => {
+ const result = mergeHeaders({
+ Host: "example.com",
+ "X-Bad-Null": "null",
+ "X-Bad-Undefined": "undefined",
+ "X-Bad-Empty": "",
+ });
+ expect(result["Host"]).toBe("example.com");
+ expect(result["X-Bad-Null"]).toBeUndefined();
+ expect(result["X-Bad-Undefined"]).toBeUndefined();
+ expect(result["X-Bad-Empty"]).toBeUndefined();
+ });
+
+ it("ignores custom headers with empty key or value", () => {
+ const result = mergeHeaders(
+ { Host: "example.com" },
+ { "": "value", "X-Valid": "ok", "X-Empty": " " },
+ );
+ expect(result[""]).toBeUndefined();
+ expect(result["X-Valid"]).toBe("ok");
+ });
+
+ it("works with no custom headers", () => {
+ const result = mergeHeaders({ Host: "example.com" });
+ expect(Object.keys(result).length).toBeGreaterThanOrEqual(4);
+ });
+});
+
+describe("mapHttpStatusToError", () => {
+ it("returns auth error for 401", () => {
+ const result = mapHttpStatusToError(401, "");
+ expect(result).toBeDefined();
+ expect(result?.kind).toBe("Error");
+ if (result?.kind === "Error") {
+ expect(result.error).toContain("401");
+ expect(result.error).toContain("Authentication");
+ }
+ });
+
+ it("returns forbidden error for 403", () => {
+ const result = mapHttpStatusToError(403, "");
+ expect(result?.kind).toBe("Error");
+ if (result?.kind === "Error") {
+ expect(result.error).toContain("403");
+ }
+ });
+
+ it("returns not found error for 404", () => {
+ const result = mapHttpStatusToError(404, "");
+ expect(result?.kind).toBe("Error");
+ if (result?.kind === "Error") {
+ expect(result.error).toContain("404");
+ }
+ });
+
+ it("returns method not allowed for 405", () => {
+ const result = mapHttpStatusToError(405, "");
+ expect(result?.kind).toBe("Error");
+ if (result?.kind === "Error") {
+ expect(result.error).toContain("405");
+ }
+ });
+
+ it("returns server error for 500+", () => {
+ for (const code of [500, 502, 503]) {
+ const result = mapHttpStatusToError(code, "");
+ expect(result?.kind).toBe("Error");
+ if (result?.kind === "Error") {
+ expect(result.error).toContain(String(code));
+ }
+ }
+ });
+
+ it("detects HTML page responses", () => {
+ const result = mapHttpStatusToError(
+ 302,
+ "Redirect",
+ );
+ expect(result?.kind).toBe("Error");
+ if (result?.kind === "Error") {
+ expect(result.error).toContain("HTML page");
+ }
+ });
+
+ it("returns preview for unknown non-200 status", () => {
+ const result = mapHttpStatusToError(418, "I'm a teapot");
+ expect(result?.kind).toBe("Error");
+ if (result?.kind === "Error") {
+ expect(result.error).toContain("418");
+ expect(result.error).toContain("teapot");
+ }
+ });
+
+ it("returns undefined for 200 (no error)", () => {
+ const result = mapHttpStatusToError(200, "");
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/packages/backend/src/services/graphql/requestUtils.ts b/packages/backend/src/services/graphql/requestUtils.ts
new file mode 100644
index 0000000..5585fdf
--- /dev/null
+++ b/packages/backend/src/services/graphql/requestUtils.ts
@@ -0,0 +1,146 @@
+type ParsedRawRequest = {
+ headers: Record;
+ body: string;
+};
+
+export function parseRawHttpRequest(rawText: string): ParsedRawRequest {
+ const lines = rawText.split(/\r?\n/);
+ const headers: Record = {};
+ let body = "";
+
+ let inHeaders = false;
+ let bodyStartIndex = -1;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (line === undefined) continue;
+
+ const trimmedLine = line.trim();
+
+ if (i === 0) {
+ inHeaders = true;
+ continue;
+ }
+
+ if (inHeaders === true && trimmedLine === "") {
+ bodyStartIndex = i + 1;
+ break;
+ }
+
+ if (
+ inHeaders === true &&
+ typeof trimmedLine === "string" &&
+ trimmedLine.includes(":")
+ ) {
+ const colonIndex = trimmedLine.indexOf(":");
+ const headerName = trimmedLine.substring(0, colonIndex).trim();
+ const headerValue = trimmedLine.substring(colonIndex + 1).trim();
+ if (
+ headerName !== "" &&
+ headerValue !== "" &&
+ headerName.toLowerCase() !== "content-length"
+ ) {
+ headers[headerName] = headerValue;
+ }
+ }
+ }
+
+ if (bodyStartIndex > 0 && bodyStartIndex < lines.length) {
+ body = lines.slice(bodyStartIndex).join("\r\n").trim();
+ }
+
+ return { headers, body };
+}
+
+export function mergeHeaders(
+ originalHeaders: Record,
+ customHeaders?: Record,
+): Record {
+ const headers: Record = {
+ ...originalHeaders,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "User-Agent": "Caido/GraphQL-Analyzer",
+ };
+
+ if (customHeaders !== undefined && typeof customHeaders === "object") {
+ Object.entries(customHeaders).forEach(([key, value]) => {
+ if (key.trim() !== "" && value.trim() !== "") {
+ headers[key] = value;
+ }
+ });
+ }
+
+ for (const [key, value] of Object.entries(headers)) {
+ if (
+ typeof value !== "string" ||
+ value === "" ||
+ value === "null" ||
+ value === "undefined"
+ ) {
+ delete headers[key];
+ }
+ }
+
+ return headers;
+}
+
+export function mapHttpStatusToError(
+ statusCode: number,
+ responseBody: string,
+): { kind: "Error"; error: string } | undefined {
+ if (statusCode === 401) {
+ return {
+ kind: "Error",
+ error:
+ "Authentication required (HTTP 401). Add Authorization, Cookie, or API key headers.",
+ };
+ }
+
+ if (statusCode === 403) {
+ return {
+ kind: "Error",
+ error:
+ "Access forbidden (HTTP 403). Your credentials lack required permissions.",
+ };
+ }
+
+ if (statusCode === 404) {
+ return {
+ kind: "Error",
+ error: "Endpoint not found (HTTP 404). Verify the URL is correct.",
+ };
+ }
+
+ if (statusCode === 405) {
+ return {
+ kind: "Error",
+ error:
+ "Method not allowed (HTTP 405). This endpoint may not support POST requests.",
+ };
+ }
+
+ if (statusCode >= 500) {
+ return {
+ kind: "Error",
+ error: `Server error (HTTP ${statusCode}). The server is experiencing issues.`,
+ };
+ }
+
+ if (statusCode !== 200) {
+ const trimmed = responseBody.trim();
+ if (trimmed.startsWith(" {
+ it("returns the first element when given an array", () => {
+ const batched = [{ data: { __typename: "Query" } }];
+ expect(unwrapBatchedResponse(batched)).toEqual({
+ data: { __typename: "Query" },
+ });
+ });
+
+ it("returns the object as-is when not an array", () => {
+ const standard = { data: { __typename: "Query" } };
+ expect(unwrapBatchedResponse(standard)).toEqual(standard);
+ });
+
+ it("returns empty array as-is", () => {
+ expect(unwrapBatchedResponse([])).toEqual([]);
+ });
+
+ it("handles null/undefined", () => {
+ expect(unwrapBatchedResponse(undefined)).toBeUndefined();
+ });
+});
+
+describe("parseIntrospectionResponseBody", () => {
+ const standardBody = JSON.stringify({
+ data: { __schema: minimalIntrospectionSchema },
+ });
+
+ const batchedBody = JSON.stringify([
+ { data: { __schema: minimalIntrospectionSchema } },
+ ]);
+
+ it("parses standard introspection response", () => {
+ const result = parseIntrospectionResponseBody(standardBody);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.supportsIntrospection).toBe(true);
+ expect(result.value.schema).toBeDefined();
+ expect(result.value.schema?.queries.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("parses batched introspection response", () => {
+ const result = parseIntrospectionResponseBody(batchedBody);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.supportsIntrospection).toBe(true);
+ expect(result.value.schema).toBeDefined();
+ expect(result.value.schema?.queries.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("produces identical schemas for standard and batched responses", () => {
+ const standardResult = parseIntrospectionResponseBody(standardBody);
+ const batchedResult = parseIntrospectionResponseBody(batchedBody);
+
+ expect(standardResult.kind).toBe("Ok");
+ expect(batchedResult.kind).toBe("Ok");
+
+ if (standardResult.kind === "Ok" && batchedResult.kind === "Ok") {
+ expect(batchedResult.value.schema?.queries).toEqual(
+ standardResult.value.schema?.queries,
+ );
+ expect(batchedResult.value.schema?.mutations).toEqual(
+ standardResult.value.schema?.mutations,
+ );
+ expect(batchedResult.value.schema?.types).toEqual(
+ standardResult.value.schema?.types,
+ );
+ }
+ });
+
+ it("detects introspection disabled errors", () => {
+ const body = JSON.stringify({
+ errors: [{ message: "Introspection is not allowed" }],
+ });
+ const result = parseIntrospectionResponseBody(body);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.supportsIntrospection).toBe(false);
+ }
+ });
+
+ it("detects introspection disabled in batched error response", () => {
+ const body = JSON.stringify([
+ { errors: [{ message: "Introspection is disabled" }] },
+ ]);
+ const result = parseIntrospectionResponseBody(body);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.supportsIntrospection).toBe(false);
+ }
+ });
+
+ it("returns GraphQL error for non-introspection errors", () => {
+ const body = JSON.stringify({
+ errors: [{ message: "Syntax error in query" }],
+ });
+ const result = parseIntrospectionResponseBody(body);
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toContain("Syntax error in query");
+ }
+ });
+
+ it("returns error for data without __schema", () => {
+ const body = JSON.stringify({ data: { users: [] } });
+ const result = parseIntrospectionResponseBody(body);
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toContain("introspection is disabled");
+ }
+ });
+
+ it("returns error for non-GraphQL JSON", () => {
+ const body = JSON.stringify({ status: "ok" });
+ const result = parseIntrospectionResponseBody(body);
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toContain("not a valid GraphQL response");
+ }
+ });
+
+ it("returns error for invalid JSON", () => {
+ const result = parseIntrospectionResponseBody("not-json");
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toContain("Invalid JSON response");
+ }
+ });
+
+ it("returns error for empty string", () => {
+ const result = parseIntrospectionResponseBody("");
+ expect(result.kind).toBe("Error");
+ });
+});
+
+describe("parseQueryResponseBody", () => {
+ it("parses standard query response", () => {
+ const body = JSON.stringify({ data: { users: [{ id: "1" }] } });
+ const result = parseQueryResponseBody(body);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value).toEqual({ data: { users: [{ id: "1" }] } });
+ }
+ });
+
+ it("unwraps batched query response", () => {
+ const body = JSON.stringify([
+ { data: { users: [{ id: "1" }] } },
+ { data: { posts: [] } },
+ ]);
+ const result = parseQueryResponseBody(body);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value).toEqual({ data: { users: [{ id: "1" }] } });
+ }
+ });
+
+ it("returns error for invalid JSON", () => {
+ const result = parseQueryResponseBody("not json");
+ expect(result.kind).toBe("Error");
+ });
+});
diff --git a/packages/backend/src/services/graphql/responseParser.ts b/packages/backend/src/services/graphql/responseParser.ts
new file mode 100644
index 0000000..c932c8d
--- /dev/null
+++ b/packages/backend/src/services/graphql/responseParser.ts
@@ -0,0 +1,98 @@
+import type { GraphQLSchema, IntrospectionSchema, Result } from "shared";
+
+import { parseIntrospectionResult } from "./parser";
+
+type IntrospectionResult = {
+ supportsIntrospection: boolean;
+ schema?: GraphQLSchema;
+};
+
+export function unwrapBatchedResponse(parsed: unknown): unknown {
+ if (Array.isArray(parsed) && parsed.length > 0) {
+ return parsed[0];
+ }
+ return parsed;
+}
+
+export function parseIntrospectionResponseBody(
+ responseBody: string,
+): Result {
+ try {
+ const parsed = JSON.parse(responseBody);
+ const jsonResponse = unwrapBatchedResponse(parsed) as Record<
+ string,
+ unknown
+ >;
+
+ if (Array.isArray(jsonResponse.errors) && jsonResponse.errors.length > 0) {
+ const introspectionDisabled = jsonResponse.errors.some(
+ (error: { message?: string }) => {
+ const message = error.message;
+ return (
+ typeof message === "string" &&
+ (message.toLowerCase().includes("introspection") ||
+ message.toLowerCase().includes("disabled") ||
+ message.toLowerCase().includes("not allowed"))
+ );
+ },
+ );
+
+ if (introspectionDisabled === true) {
+ return { kind: "Ok", value: { supportsIntrospection: false } };
+ }
+
+ const errorMessages = (jsonResponse.errors as Array<{ message?: string }>)
+ .map((e) => e.message ?? "Unknown error")
+ .join(", ");
+ return {
+ kind: "Error",
+ error: `GraphQL error: ${errorMessages}`,
+ };
+ }
+
+ if (
+ jsonResponse.data !== undefined &&
+ (jsonResponse.data as Record).__schema !== undefined
+ ) {
+ const schema = parseIntrospectionResult(
+ (jsonResponse.data as Record)
+ .__schema as IntrospectionSchema,
+ );
+ (
+ schema as GraphQLSchema & { rawIntrospection?: unknown }
+ ).rawIntrospection = jsonResponse.data;
+ return { kind: "Ok", value: { supportsIntrospection: true, schema } };
+ }
+
+ if (jsonResponse.data !== undefined) {
+ return {
+ kind: "Error",
+ error:
+ "GraphQL endpoint responded but introspection is disabled or not available.",
+ };
+ }
+
+ return {
+ kind: "Error",
+ error: "Endpoint returned JSON but it's not a valid GraphQL response.",
+ };
+ } catch {
+ const preview = responseBody.substring(0, 100);
+ return { kind: "Error", error: `Invalid JSON response: ${preview}...` };
+ }
+}
+
+export function parseQueryResponseBody(
+ responseBody: string,
+): Result> {
+ try {
+ const parsed = JSON.parse(responseBody);
+ const json = unwrapBatchedResponse(parsed);
+ return { kind: "Ok", value: json as Record };
+ } catch {
+ return {
+ kind: "Error",
+ error: "Invalid JSON response",
+ };
+ }
+}
diff --git a/packages/backend/src/services/graphql/schemaImporter/detection.test.ts b/packages/backend/src/services/graphql/schemaImporter/detection.test.ts
new file mode 100644
index 0000000..1bcc805
--- /dev/null
+++ b/packages/backend/src/services/graphql/schemaImporter/detection.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+
+import { detectSchemaFormat } from "./detection";
+import { minimalIntrospectionSchema } from "./fixtures/minimalSchema";
+
+describe("detectSchemaFormat", () => {
+ it("detects wrapped introspection format: { data: { __schema: ... } }", () => {
+ const data = { data: { __schema: minimalIntrospectionSchema } };
+ const result = detectSchemaFormat(data);
+ expect(result.format).toBe("introspection-wrapped");
+ expect(result.schema).toBeDefined();
+ expect(result.error).toBeUndefined();
+ });
+
+ it("detects unwrapped introspection format: { __schema: ... }", () => {
+ const data = { __schema: minimalIntrospectionSchema };
+ const result = detectSchemaFormat(data);
+ expect(result.format).toBe("introspection-unwrapped");
+ expect(result.schema).toBeDefined();
+ expect(result.error).toBeUndefined();
+ });
+
+ it("detects direct schema object format", () => {
+ const result = detectSchemaFormat(minimalIntrospectionSchema);
+ expect(result.format).toBe("introspection-direct");
+ expect(result.schema).toBeDefined();
+ expect(result.error).toBeUndefined();
+ });
+
+ it("returns unknown for unrelated JSON object", () => {
+ const result = detectSchemaFormat({ name: "not a schema", items: [] });
+ expect(result.format).toBe("unknown");
+ expect(result.schema).toBeUndefined();
+ expect(result.error).toBeDefined();
+ });
+
+ it("returns unknown for null input", () => {
+ const result = detectSchemaFormat(null);
+ expect(result.format).toBe("unknown");
+ expect(result.error).toBe("Input is not a valid object");
+ });
+
+ it("returns unknown for a primitive value", () => {
+ const result = detectSchemaFormat("just a string" as unknown);
+ expect(result.format).toBe("unknown");
+ });
+
+ it("returns unknown for an empty object", () => {
+ const result = detectSchemaFormat({});
+ expect(result.format).toBe("unknown");
+ });
+
+ it("rejects __schema that has no types array", () => {
+ const result = detectSchemaFormat({
+ __schema: { queryType: { name: "Query" } },
+ });
+ expect(result.format).toBe("unknown");
+ });
+});
diff --git a/packages/backend/src/services/graphql/schemaImporter/detection.ts b/packages/backend/src/services/graphql/schemaImporter/detection.ts
new file mode 100644
index 0000000..11a1328
--- /dev/null
+++ b/packages/backend/src/services/graphql/schemaImporter/detection.ts
@@ -0,0 +1,77 @@
+import type { IntrospectionSchema } from "shared";
+
+export type SchemaFormat =
+ | "introspection-wrapped"
+ | "introspection-unwrapped"
+ | "introspection-direct"
+ | "unknown";
+
+export type DetectionResult = {
+ format: SchemaFormat;
+ schema: IntrospectionSchema | undefined;
+ error: string | undefined;
+};
+
+export function detectSchemaFormat(data: unknown): DetectionResult {
+ if (data === undefined || data === null || typeof data !== "object") {
+ return {
+ format: "unknown",
+ schema: undefined,
+ error: "Input is not a valid object",
+ };
+ }
+
+ const obj = data as Record;
+
+ if (isWrappedIntrospection(obj)) {
+ const dataObj = obj.data as Record;
+ const schema = dataObj.__schema as IntrospectionSchema;
+ return { format: "introspection-wrapped", schema, error: undefined };
+ }
+
+ if (isUnwrappedIntrospection(obj)) {
+ const schema = obj.__schema as IntrospectionSchema;
+ return { format: "introspection-unwrapped", schema, error: undefined };
+ }
+ if (isDirectSchemaObject(obj)) {
+ const schema = obj as unknown as IntrospectionSchema;
+ return { format: "introspection-direct", schema, error: undefined };
+ }
+
+ return {
+ format: "unknown",
+ schema: undefined,
+ error:
+ "JSON does not match any known GraphQL introspection format. " +
+ 'Expected one of: { "data": { "__schema": ... } }, ' +
+ '{ "__schema": ... }, or a direct schema object with "queryType" and "types".',
+ };
+}
+
+function isWrappedIntrospection(obj: Record): boolean {
+ if (
+ obj.data === undefined ||
+ obj.data === null ||
+ typeof obj.data !== "object"
+ ) {
+ return false;
+ }
+ const dataObj = obj.data as Record;
+ return hasSchemaShape(dataObj.__schema);
+}
+
+function isUnwrappedIntrospection(obj: Record): boolean {
+ return hasSchemaShape(obj.__schema);
+}
+
+function isDirectSchemaObject(obj: Record): boolean {
+ return Array.isArray(obj.types) && obj.types.length > 0;
+}
+
+function hasSchemaShape(value: unknown): boolean {
+ if (value === undefined || value === null || typeof value !== "object") {
+ return false;
+ }
+ const schema = value as Record;
+ return Array.isArray(schema.types);
+}
diff --git a/packages/backend/src/services/graphql/schemaImporter/fixtures/minimalSchema.ts b/packages/backend/src/services/graphql/schemaImporter/fixtures/minimalSchema.ts
new file mode 100644
index 0000000..1c5ae97
--- /dev/null
+++ b/packages/backend/src/services/graphql/schemaImporter/fixtures/minimalSchema.ts
@@ -0,0 +1,234 @@
+export const minimalIntrospectionSchema = {
+ queryType: { name: "Query" },
+ mutationType: { name: "Mutation" },
+ subscriptionType: null,
+ types: [
+ {
+ kind: "OBJECT",
+ name: "Query",
+ description: "Root query type",
+ fields: [
+ {
+ name: "user",
+ description: "Get a user by ID",
+ args: [
+ {
+ name: "id",
+ description: null,
+ type: {
+ kind: "NON_NULL",
+ name: null,
+ ofType: { kind: "SCALAR", name: "ID", ofType: null },
+ },
+ defaultValue: null,
+ },
+ ],
+ type: { kind: "OBJECT", name: "User", ofType: null },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ {
+ name: "users",
+ description: "List all users",
+ args: [],
+ type: {
+ kind: "LIST",
+ name: null,
+ ofType: { kind: "OBJECT", name: "User", ofType: null },
+ },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ ],
+ inputFields: null,
+ interfaces: [],
+ enumValues: null,
+ possibleTypes: null,
+ },
+ {
+ kind: "OBJECT",
+ name: "Mutation",
+ description: "Root mutation type",
+ fields: [
+ {
+ name: "createUser",
+ description: "Create a new user",
+ args: [
+ {
+ name: "name",
+ description: null,
+ type: {
+ kind: "NON_NULL",
+ name: null,
+ ofType: { kind: "SCALAR", name: "String", ofType: null },
+ },
+ defaultValue: null,
+ },
+ {
+ name: "email",
+ description: null,
+ type: {
+ kind: "NON_NULL",
+ name: null,
+ ofType: { kind: "SCALAR", name: "String", ofType: null },
+ },
+ defaultValue: null,
+ },
+ ],
+ type: { kind: "OBJECT", name: "User", ofType: null },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ ],
+ inputFields: null,
+ interfaces: [],
+ enumValues: null,
+ possibleTypes: null,
+ },
+ {
+ kind: "OBJECT",
+ name: "User",
+ description: "A user in the system",
+ fields: [
+ {
+ name: "id",
+ description: null,
+ args: [],
+ type: {
+ kind: "NON_NULL",
+ name: null,
+ ofType: { kind: "SCALAR", name: "ID", ofType: null },
+ },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ {
+ name: "name",
+ description: null,
+ args: [],
+ type: { kind: "SCALAR", name: "String", ofType: null },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ {
+ name: "email",
+ description: null,
+ args: [],
+ type: { kind: "SCALAR", name: "String", ofType: null },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ {
+ name: "role",
+ description: null,
+ args: [],
+ type: { kind: "ENUM", name: "Role", ofType: null },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ ],
+ inputFields: null,
+ interfaces: [{ kind: "INTERFACE", name: "Node", ofType: null }],
+ enumValues: null,
+ possibleTypes: null,
+ },
+ {
+ kind: "ENUM",
+ name: "Role",
+ description: "User roles",
+ fields: null,
+ inputFields: null,
+ interfaces: null,
+ enumValues: [
+ {
+ name: "ADMIN",
+ description: "Administrator",
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ {
+ name: "USER",
+ description: "Regular user",
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ ],
+ possibleTypes: null,
+ },
+ {
+ kind: "INTERFACE",
+ name: "Node",
+ description: "An object with an ID",
+ fields: [
+ {
+ name: "id",
+ description: null,
+ args: [],
+ type: {
+ kind: "NON_NULL",
+ name: null,
+ ofType: { kind: "SCALAR", name: "ID", ofType: null },
+ },
+ isDeprecated: false,
+ deprecationReason: null,
+ },
+ ],
+ inputFields: null,
+ interfaces: null,
+ enumValues: null,
+ possibleTypes: [{ kind: "OBJECT", name: "User", ofType: null }],
+ },
+ {
+ kind: "SCALAR",
+ name: "String",
+ description: "Built-in String scalar",
+ fields: null,
+ inputFields: null,
+ interfaces: null,
+ enumValues: null,
+ possibleTypes: null,
+ },
+ {
+ kind: "SCALAR",
+ name: "ID",
+ description: "Built-in ID scalar",
+ fields: null,
+ inputFields: null,
+ interfaces: null,
+ enumValues: null,
+ possibleTypes: null,
+ },
+ {
+ kind: "SCALAR",
+ name: "Boolean",
+ description: "Built-in Boolean scalar",
+ fields: null,
+ inputFields: null,
+ interfaces: null,
+ enumValues: null,
+ possibleTypes: null,
+ },
+ // Internal types (should be filtered out by parser)
+ {
+ kind: "OBJECT",
+ name: "__Schema",
+ description: null,
+ fields: [],
+ inputFields: null,
+ interfaces: [],
+ enumValues: null,
+ possibleTypes: null,
+ },
+ {
+ kind: "OBJECT",
+ name: "__Type",
+ description: null,
+ fields: [],
+ inputFields: null,
+ interfaces: [],
+ enumValues: null,
+ possibleTypes: null,
+ },
+ ],
+ directives: [],
+};
diff --git a/packages/backend/src/services/graphql/schemaImporter/index.test.ts b/packages/backend/src/services/graphql/schemaImporter/index.test.ts
new file mode 100644
index 0000000..01e03fa
--- /dev/null
+++ b/packages/backend/src/services/graphql/schemaImporter/index.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from "vitest";
+
+import { minimalIntrospectionSchema } from "./fixtures/minimalSchema";
+
+import { parseSchemaFromFileContent } from "./index";
+
+describe("parseSchemaFromFileContent", () => {
+ it("parses wrapped introspection JSON", () => {
+ const content = JSON.stringify({
+ data: { __schema: minimalIntrospectionSchema },
+ });
+ const result = parseSchemaFromFileContent(content);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.format).toBe("introspection-wrapped");
+ expect(result.value.schema.queries).toHaveLength(2);
+ expect(result.value.schema.mutations).toHaveLength(1);
+ }
+ });
+
+ it("parses unwrapped introspection JSON", () => {
+ const content = JSON.stringify({
+ __schema: minimalIntrospectionSchema,
+ });
+ const result = parseSchemaFromFileContent(content);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.format).toBe("introspection-unwrapped");
+ expect(result.value.schema.queries).toHaveLength(2);
+ }
+ });
+
+ it("parses direct schema object JSON", () => {
+ const content = JSON.stringify(minimalIntrospectionSchema);
+ const result = parseSchemaFromFileContent(content);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.format).toBe("introspection-direct");
+ expect(result.value.schema.queries).toHaveLength(2);
+ }
+ });
+
+ it("returns error for empty content", () => {
+ const result = parseSchemaFromFileContent("");
+ expect(result.kind).toBe("Error");
+ });
+
+ it("returns error for invalid JSON", () => {
+ const result = parseSchemaFromFileContent("not json at all");
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toContain("Invalid JSON");
+ }
+ });
+
+ it("returns error for valid JSON that is not a schema", () => {
+ const result = parseSchemaFromFileContent('{"users": [1, 2, 3]}');
+ expect(result.kind).toBe("Error");
+ });
+
+ it("correctly populates schema types", () => {
+ const content = JSON.stringify({
+ data: { __schema: minimalIntrospectionSchema },
+ });
+ const result = parseSchemaFromFileContent(content);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ const schema = result.value.schema;
+
+ // Queries
+ expect(schema.queries.map((q) => q.name)).toEqual(["user", "users"]);
+
+ // Mutations
+ expect(schema.mutations.map((m) => m.name)).toEqual(["createUser"]);
+
+ // Subscriptions (none in fixture)
+ expect(schema.subscriptions).toHaveLength(0);
+
+ // Custom types (User only, not Query/Mutation/internal)
+ expect(schema.types.map((t) => t.name)).toContain("User");
+ expect(schema.types.map((t) => t.name)).not.toContain("Query");
+ expect(schema.types.map((t) => t.name)).not.toContain("__Schema");
+
+ // Enums
+ expect(schema.enums.map((e) => e.name)).toContain("Role");
+ expect(
+ schema.enums.find((e) => e.name === "Role")?.values.map((v) => v.name),
+ ).toEqual(["ADMIN", "USER"]);
+
+ // Interfaces
+ expect(schema.interfaces.map((i) => i.name)).toContain("Node");
+ }
+ });
+
+ it("generates points of interest for sensitive fields", () => {
+ const content = JSON.stringify({
+ data: { __schema: minimalIntrospectionSchema },
+ });
+ const result = parseSchemaFromFileContent(content);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value.schema.pointsOfInterest).toBeDefined();
+ expect(Array.isArray(result.value.schema.pointsOfInterest)).toBe(true);
+ }
+ });
+
+ it("parses field arguments correctly", () => {
+ const content = JSON.stringify({
+ data: { __schema: minimalIntrospectionSchema },
+ });
+ const result = parseSchemaFromFileContent(content);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ const userQuery = result.value.schema.queries.find(
+ (q) => q.name === "user",
+ );
+ expect(userQuery).toBeDefined();
+ expect(userQuery?.args).toHaveLength(1);
+ expect(userQuery?.args[0]?.name).toBe("id");
+ expect(userQuery?.args[0]?.type).toBe("ID!");
+ }
+ });
+
+ it("formats type references correctly", () => {
+ const content = JSON.stringify({
+ data: { __schema: minimalIntrospectionSchema },
+ });
+ const result = parseSchemaFromFileContent(content);
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ const usersQuery = result.value.schema.queries.find(
+ (q) => q.name === "users",
+ );
+ expect(usersQuery?.type).toBe("[User]");
+
+ const userQuery = result.value.schema.queries.find(
+ (q) => q.name === "user",
+ );
+ expect(userQuery?.type).toBe("User");
+ }
+ });
+});
diff --git a/packages/backend/src/services/graphql/schemaImporter/index.ts b/packages/backend/src/services/graphql/schemaImporter/index.ts
new file mode 100644
index 0000000..c594391
--- /dev/null
+++ b/packages/backend/src/services/graphql/schemaImporter/index.ts
@@ -0,0 +1,41 @@
+import type { Result, SchemaImportResult } from "shared";
+
+import { parseIntrospectionResult } from "../parser";
+
+import { detectSchemaFormat } from "./detection";
+import { parseJsonContent } from "./jsonParser";
+
+export function parseSchemaFromFileContent(
+ content: string,
+): Result {
+ const jsonResult = parseJsonContent(content);
+ if (jsonResult.kind === "Error") {
+ return jsonResult;
+ }
+
+ const detection = detectSchemaFormat(jsonResult.value);
+
+ if (detection.schema === undefined) {
+ return {
+ kind: "Error",
+ error: detection.error ?? "Failed to detect schema format",
+ };
+ }
+
+ try {
+ const schema = parseIntrospectionResult(detection.schema);
+ return {
+ kind: "Ok",
+ value: {
+ schema,
+ format: detection.format,
+ },
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ kind: "Error",
+ error: `Failed to parse introspection schema: ${message}`,
+ };
+ }
+}
diff --git a/packages/backend/src/services/graphql/schemaImporter/jsonParser.test.ts b/packages/backend/src/services/graphql/schemaImporter/jsonParser.test.ts
new file mode 100644
index 0000000..9b98959
--- /dev/null
+++ b/packages/backend/src/services/graphql/schemaImporter/jsonParser.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+
+import { parseJsonContent } from "./jsonParser";
+
+describe("parseJsonContent", () => {
+ it("parses valid JSON", () => {
+ const result = parseJsonContent('{"key": "value"}');
+ expect(result.kind).toBe("Ok");
+ if (result.kind === "Ok") {
+ expect(result.value).toEqual({ key: "value" });
+ }
+ });
+
+ it("returns error for empty string", () => {
+ const result = parseJsonContent("");
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toBe("File is empty");
+ }
+ });
+
+ it("returns error for whitespace-only string", () => {
+ const result = parseJsonContent(" \n\t ");
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toBe("File is empty");
+ }
+ });
+
+ it("returns error for invalid JSON", () => {
+ const result = parseJsonContent("{ broken json }");
+ expect(result.kind).toBe("Error");
+ if (result.kind === "Error") {
+ expect(result.error).toContain("Invalid JSON");
+ }
+ });
+});
diff --git a/packages/backend/src/services/graphql/schemaImporter/jsonParser.ts b/packages/backend/src/services/graphql/schemaImporter/jsonParser.ts
new file mode 100644
index 0000000..f33d8a1
--- /dev/null
+++ b/packages/backend/src/services/graphql/schemaImporter/jsonParser.ts
@@ -0,0 +1,20 @@
+import type { Result } from "shared";
+
+export function parseJsonContent(content: string): Result {
+ const trimmed = content.trim();
+
+ if (trimmed === "") {
+ return { kind: "Error", error: "File is empty" };
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ return { kind: "Ok", value: parsed };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ kind: "Error",
+ error: `Invalid JSON: ${message}`,
+ };
+ }
+}
diff --git a/packages/frontend/src/components/Explorer/CodePanel.vue b/packages/frontend/src/components/Explorer/CodePanel.vue
index 2887b7b..c76b678 100644
--- a/packages/frontend/src/components/Explorer/CodePanel.vue
+++ b/packages/frontend/src/components/Explorer/CodePanel.vue
@@ -1,5 +1,6 @@
@@ -19,6 +26,14 @@ import Card from "primevue/card";
information and security insights.
+
diff --git a/packages/frontend/src/components/dashboard/ImportSchemaDialog.vue b/packages/frontend/src/components/dashboard/ImportSchemaDialog.vue
new file mode 100644
index 0000000..316090c
--- /dev/null
+++ b/packages/frontend/src/components/dashboard/ImportSchemaDialog.vue
@@ -0,0 +1,259 @@
+
+
+
+
+
diff --git a/packages/frontend/src/components/dashboard/RecentActivity.vue b/packages/frontend/src/components/dashboard/RecentActivity.vue
index c0a9751..c96a179 100644
--- a/packages/frontend/src/components/dashboard/RecentActivity.vue
+++ b/packages/frontend/src/components/dashboard/RecentActivity.vue
@@ -1,17 +1,7 @@