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 @@ 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 @@