diff --git a/Changelog.md b/Changelog.md index b4a5adfa8..d48e2459e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -66,6 +66,8 @@ And as always, there are many additional small fixes and improvements. ([#839](https://github.com/aws/graph-explorer/pull/839)) - **Fixed** proxy server error when a request is aborted mid-stream ([#873](https://github.com/aws/graph-explorer/pull/873)) +- **Fixed** issue rendering error responses that include HTML + ([#905](https://github.com/aws/graph-explorer/pull/905)) - **Updated** icons in context menu to be more consistent ([#906](https://github.com/aws/graph-explorer/pull/906)) - **Updated** styling of buttons, input, textarea, and select fields diff --git a/packages/graph-explorer-proxy-server/src/node-server.ts b/packages/graph-explorer-proxy-server/src/node-server.ts index a3fecdd38..5cfdbb158 100644 --- a/packages/graph-explorer-proxy-server/src/node-server.ts +++ b/packages/graph-explorer-proxy-server/src/node-server.ts @@ -280,88 +280,11 @@ app.post("/sparql", (req, res, next) => { }); // POST endpoint for Gremlin queries. -app.post("/gremlin", (req, res, next) => { - // Gather info from the headers - const headers = req.headers as DbQueryIncomingHttpHeaders; - const queryId = headers["queryid"]; - const graphDbConnectionUrl = headers["graph-db-connection-url"]; - const shouldLogDbQuery = BooleanStringSchema.default("false").parse( - headers["db-query-logging-enabled"] - ); - const isIamEnabled = !!headers["aws-neptune-region"]; - const region = isIamEnabled ? headers["aws-neptune-region"] : ""; - const serviceType = isIamEnabled - ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) - : ""; - - // Validate the input before making any external calls. - const queryString = req.body.query; - if (!queryString) { - return res - .status(400) - .send({ error: "[Proxy] Gremlin: query not provided" }); - } - - if (shouldLogDbQuery) { - proxyLogger.debug("[Gremlin] Received database query:\n%s", queryString); - } - - /// Function to cancel long running queries if the client disappears before completion - async function cancelQuery() { - if (!queryId) { - return; - } - proxyLogger.debug(`Cancelling request ${queryId}...`); - try { - await retryFetch( - new URL( - `${graphDbConnectionUrl}/gremlin/status?cancelQuery&queryId=${encodeURIComponent(queryId)}` - ), - { method: "GET" }, - isIamEnabled, - region, - serviceType - ); - } catch (err) { - // Not really an error - proxyLogger.warn("Failed to cancel the query: %o", err); - } - } - - // Watch for a cancelled or aborted connection - req.on("close", async () => { - if (req.complete) { - return; - } - await cancelQuery(); - }); - res.on("close", async () => { - if (res.writableFinished) { - return; - } - await cancelQuery(); - }); - - const body = { gremlin: queryString, queryId }; - const rawUrl = `${graphDbConnectionUrl}/gremlin`; - const requestOptions = { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/vnd.gremlin-v3.0+json", - }, - body: JSON.stringify(body), - }; - - return fetchData( - res, - next, - rawUrl, - requestOptions, - isIamEnabled, - region, - serviceType - ); +app.post("/gremlin", (_req, res, _next) => { + // Respond with a 500 and html content + return res + .status(500) + .send("500 Internal Server Error"); }); // POST endpoint for openCypher queries. diff --git a/packages/graph-explorer/src/components/Dialog.tsx b/packages/graph-explorer/src/components/Dialog.tsx index 60e59d6ec..739cb9a4d 100644 --- a/packages/graph-explorer/src/components/Dialog.tsx +++ b/packages/graph-explorer/src/components/Dialog.tsx @@ -32,11 +32,11 @@ const DialogContent = React.forwardRef< >(({ className, children, ...props }, ref) => ( -
+
{ + it("should decode text responses", async () => { + const response = new Response("Some error message", { + status: 500, + headers: { + "Content-Type": "text/plain", + }, + }); + + const decoded = await decodeErrorSafely(response); + + expect(decoded).toEqual({ + status: 500, + contentType: "text/plain", + content: "text", + text: "Some error message", + }); + }); + + it("should decode HTML responses", async () => { + const response = new Response("", { + status: 500, + headers: { + "Content-Type": "text/html", + }, + }); + + const decoded = await decodeErrorSafely(response); + + expect(decoded).toEqual({ + status: 500, + contentType: "text/html", + content: "text", + text: "", + }); + }); + + it("should decode xml responses", async () => { + const response = new Response("", { + status: 500, + headers: { + "Content-Type": "text/xml", + }, + }); + + const decoded = await decodeErrorSafely(response); + + expect(decoded).toEqual({ + status: 500, + contentType: "text/xml", + content: "text", + text: "", + }); + }); + + it("should return the error message inside the JSON response", async () => { + const response = new Response( + JSON.stringify({ + message: "An error occurred", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + }, + } + ); + + const decoded = await decodeErrorSafely(response); + + expect(decoded).toEqual({ + status: 500, + contentType: "application/json", + content: "data", + data: { message: "An error occurred" }, + }); + }); + + it("should return the flattened error message inside the JSON response", async () => { + const response = new Response( + JSON.stringify({ + error: { message: "An error occurred" }, + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + }, + } + ); + + const decoded = await decodeErrorSafely(response); + + expect(decoded).toEqual({ + status: 500, + contentType: "application/json", + content: "data", + data: { + message: "An error occurred", + }, + }); + }); + + it("should return undefined when the error contains no content", async () => { + const response = new Response(null, { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }); + + const decoded = await decodeErrorSafely(response); + + expect(decoded).toEqual({ + status: 500, + contentType: "application/json", + content: "empty", + }); + }); +}); diff --git a/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts b/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts index 8f421822f..55326fdb3 100644 --- a/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts +++ b/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts @@ -15,44 +15,71 @@ const NeptuneErrorSchema = z.object({ type: z.string().optional(), }); +interface ErrorDataResponse { + content: "data"; + data: any; +} + +interface ErrorTextResponse { + content: "text"; + text: string; +} + +interface ErrorEmptyResponse { + content: "empty"; +} + +type ErrorResponse = { + status: number; + contentType: string | null; +} & (ErrorDataResponse | ErrorTextResponse | ErrorEmptyResponse); + /** * Attempts to decode the error response into a JSON object. * * @param response The fetch response that should be decoded * @returns The decoded response or undefined if it fails to decode */ -async function decodeErrorSafely(response: Response): Promise { - const contentType = response.headers.get("Content-Type"); - const contentTypeHasValue = contentType !== null && contentType.length > 0; - // Assume missing content type is JSON - const isJson = - !contentTypeHasValue || contentType.includes("application/json"); +export async function decodeErrorSafely( + response: Response +): Promise { + const contentType = response.headers.get("Content-Type") ?? null; + const status = response.status; // Extract the raw text from the response const rawText = await response.text(); - - // Check for empty response if (!rawText) { - return undefined; + return { + status, + contentType, + content: "empty", + }; } - if (isJson) { + // Attempt to parse JSON + if (contentType?.includes("application/json")) { try { // Try parsing the response as JSON const data = JSON.parse(rawText); // Flatten the error if it contains an error object - return data?.error ?? data; + return { + status, + contentType, + content: "data", + data: data?.error ?? data, + }; } catch (error) { - console.error("Failed to decode the error response as JSON", { + // Log the error and fallthrough to return the raw text + logger.warn("Failed to decode the error response as JSON", { error, rawText, }); - return rawText; } } - return { message: rawText }; + // Just return the whole response as the error + return { status, contentType, content: "text", text: rawText }; } // Construct the request headers based on the connection settings @@ -111,6 +138,11 @@ export async function fetchDatabaseRequest( // Log the error to the console always logger.error(`Response status ${response.status} received:`, error); + if (error.content === "text") { + if (error.contentType?.includes("text/plain")) { + } + } + // Parse out neptune specific error messages const parseNeptuneError = NeptuneErrorSchema.safeParse(error); if (parseNeptuneError.success) { @@ -129,3 +161,62 @@ export async function fetchDatabaseRequest( const data = await response.json(); return data; } + +function getNeptuneErrorMessage(data: unknown) { + // Parse out neptune specific error messages + const parseNeptuneError = NeptuneErrorSchema.safeParse(data); + if (!parseNeptuneError.success) { + return null; + } + + // Prefer the detailed message if it exists + const message = + parseNeptuneError.data.detailedMessage ?? + parseNeptuneError.data.message ?? + null; + return message; +} + +function getDataOrText(response: Response) { + const contentType = response.headers.get("Content-Type") ?? null; + + const rawText = await response.text(); + if (!rawText) { + return null; + } + + if (contentType?.includes("application/json")) { + try { + // Try parsing the response as JSON + return JSON.parse(rawText); + } catch (error) { + // Ignore the error and return the raw text + return rawText; + } + } + + return rawText +} + +function createNetworkErrorFromResponse(response: Response) { + const error = decodeErrorSafely(response); + const contentType = response.headers.get("Content-Type") ?? null; + const textOrData = + + // Log the error to the console always + logger.error(`Response status ${response.status} received:`, { + error, + contentType, + }); + + if (!contentType) { + throw new NetworkError("Network response was not OK", response.status, null); + } + + if (contentType) { + throw new NetworkError( + "Network response was not OK", + response.status, + error + ); +}