Files
cosmos-explorer/src/Explorer/Graph/GraphExplorerComponent/GremlinSimpleClient.ts
2020-05-25 21:30:55 -05:00

358 lines
11 KiB
TypeScript

/**
* Lightweight gremlin client javascript library for the browser:
* - specs: http://tinkerpop.apache.org/docs/3.0.1-incubating/#_developing_a_driver
* - inspired from gremlin-javascript for nodejs: https://github.com/jbmusso/gremlin-javascript
* - tested on cosmosdb gremlin server
* - only supports sessionless gremlin requests
* - Relies on text-encoding polyfill (github.com/inexorabletash/text-encoding) for TextEncoder/TextDecoder on IE, Edge.
*/
import { TextEncoder, TextDecoder } from "text-encoding";
export interface GremlinSimpleClientParameters {
endpoint: string; // The websocket endpoint
user: string;
password: string;
successCallback: (result: Result) => void;
progressCallback: (result: Result) => void;
failureCallback: (result: Result, error: string) => void;
infoCallback: (msg: string) => void;
}
export interface Result {
requestId: string; // Can be null
data: any;
requestCharge: number; // RU cost
}
// Args are for Standard OpProcessor: sessionless requests
export interface GremlinRequestMessage {
requestId: string;
op: "eval" | "authentication";
processor: string;
args:
| {
gremlin: string;
bindings: {};
language: string;
}
| {
SASL: string;
};
}
export interface GremlinResponseMessage {
requestId: string;
status: {
attributes: {
/* The following fields are DEPRECATED. DO NOT USE.
StorageRU: string;
"x-ms-cosmosdb-graph-request-charge": number;
*/
"x-ms-request-charge": number;
"x-ms-total-request-charge": number;
};
code: number;
message: string;
};
result: {
data: any;
};
}
export class GremlinSimpleClient {
private static readonly requestChargeHeader = "x-ms-request-charge";
public params: GremlinSimpleClientParameters;
private protocols: string | string[];
private ws: WebSocket;
public requestsToSend: { [requestId: string]: GremlinRequestMessage };
public pendingRequests: { [requestId: string]: GremlinRequestMessage };
constructor(params: GremlinSimpleClientParameters) {
this.params = params;
this.pendingRequests = {};
this.requestsToSend = {};
}
public connect() {
if (this.ws) {
if (this.ws.readyState === WebSocket.CONNECTING) {
// Wait until it connects to execute all requests
return;
}
if (this.ws.readyState === WebSocket.OPEN) {
// Connection already open
this.executeRequestsToSend();
return;
}
}
this.close();
const msg = `Connecting to ${this.params.endpoint} as ${this.params.user}`;
if (this.params.infoCallback) {
this.params.infoCallback(msg);
}
this.ws = GremlinSimpleClient.createWebSocket(this.params.endpoint);
this.ws.onopen = this.onOpen.bind(this);
this.ws.onerror = this.onError.bind(this);
this.ws.onmessage = this.onMessage.bind(this);
this.ws.onclose = this.onClose.bind(this);
this.ws.binaryType = "arraybuffer";
}
public static createWebSocket(endpoint: string): WebSocket {
return new WebSocket(endpoint);
}
public close() {
if (this.ws && this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED) {
const msg = `Disconnecting from ${this.params.endpoint} as ${this.params.user}`;
console.log(msg);
if (this.params.infoCallback) {
this.params.infoCallback(msg);
}
this.ws.close();
}
}
public decodeMessage(msg: MessageEvent): GremlinResponseMessage {
if (msg.data === null) {
return null;
}
if (msg.data.byteLength === 0) {
if (this.params.infoCallback) {
this.params.infoCallback("Received empty response");
}
return null;
}
try {
// msg.data is an ArrayBuffer of utf-8 characters, but handle string just in case
const data = typeof msg.data === "string" ? msg.data : new TextDecoder("utf-8").decode(msg.data);
return JSON.parse(data);
} catch (e) {
console.error(e, msg);
if (this.params.failureCallback) {
this.params.failureCallback(
null,
`Unexpected error while decoding backend response: ${e} msg:${JSON.stringify(msg)}`
);
}
return null;
}
}
public onMessage(msg: MessageEvent) {
if (!msg) {
if (this.params.failureCallback) {
this.params.failureCallback(null, "onMessage called with no message");
}
return;
}
const rawMessage = this.decodeMessage(msg);
if (!rawMessage) {
return;
}
const requestId = rawMessage.requestId;
const statusCode = rawMessage.status.code;
const statusMessage = rawMessage.status.message;
const result: Result = {
requestId: requestId,
data: rawMessage.result ? rawMessage.result.data : null,
requestCharge: rawMessage.status.attributes[GremlinSimpleClient.requestChargeHeader]
};
if (!this.pendingRequests[requestId]) {
if (this.params.failureCallback) {
this.params.failureCallback(
result,
`Received response for missing or closed request: ${requestId} code:${statusCode} message:${statusMessage}`
);
}
return;
}
switch (statusCode) {
case 200: // Success
delete this.pendingRequests[requestId];
if (this.params.successCallback) {
this.params.successCallback(result);
}
break;
case 204: // No content
delete this.pendingRequests[requestId];
if (this.params.successCallback) {
result.data = null;
this.params.successCallback(result);
}
break;
case 206: // Partial content
if (this.params.progressCallback) {
this.params.progressCallback(result);
}
break;
case 407: // Request authentication
const challengeResponse = this.buildChallengeResponse(this.pendingRequests[requestId]);
this.sendGremlinMessage(challengeResponse);
break;
case 401: // Unauthorized
delete this.pendingRequests[requestId];
if (this.params.failureCallback) {
this.params.failureCallback(result, `Unauthorized: ${statusMessage}`);
}
break;
case 498: // Malformed request
delete this.pendingRequests[requestId];
if (this.params.failureCallback) {
this.params.failureCallback(result, `Malformed request: ${statusMessage}`);
}
break;
case 500: // Server error
delete this.pendingRequests[requestId];
if (this.params.failureCallback) {
this.params.failureCallback(result, `Server error: ${statusMessage}`);
}
break;
case 597: // Script eval error
delete this.pendingRequests[requestId];
if (this.params.failureCallback) {
this.params.failureCallback(result, `Script eval error: ${statusMessage}`);
}
break;
case 598: // Server timeout
delete this.pendingRequests[requestId];
if (this.params.failureCallback) {
this.params.failureCallback(result, `Server timeout: ${statusMessage}`);
}
break;
case 599: // Server serialization error
delete this.pendingRequests[requestId];
if (this.params.failureCallback) {
this.params.failureCallback(result, `Server serialization error: ${statusMessage}`);
}
break;
default:
delete this.pendingRequests[requestId];
if (this.params.failureCallback) {
this.params.failureCallback(result, `Error with status code: ${statusCode}. Message: ${statusMessage}`);
}
break;
}
}
/**
* This is the main function to use in order to execute a GremlinQuery
* @param query
* @param successCallback
* @param progressCallback
* @param failureCallback
* @return requestId
*/
public executeGremlinQuery(query: string): string {
const requestId = GremlinSimpleClient.uuidv4();
this.requestsToSend[requestId] = {
requestId: requestId,
op: "eval",
processor: "",
args: {
gremlin: query,
bindings: {},
language: "gremlin-groovy"
}
};
this.connect();
return requestId;
}
public buildChallengeResponse(request: GremlinRequestMessage): GremlinRequestMessage {
var args = {
SASL: GremlinSimpleClient.utf8ToB64("\0" + this.params.user + "\0" + this.params.password)
};
return {
requestId: request.requestId,
processor: request.processor,
op: "authentication",
args
};
}
public static utf8ToB64(utf8Str: string) {
return btoa(
encodeURIComponent(utf8Str).replace(/%([0-9A-F]{2})/g, function(match, p1) {
return String.fromCharCode(parseInt(p1, 16));
})
);
}
/**
* Gremlin binary frame is:
* mimeLength + mimeType + serialized message
* @param requestMessage
*/
public static buildGremlinMessage(requestMessage: {}): Uint8Array {
const mimeType = "application/json";
let serializedMessage = mimeType + JSON.stringify(requestMessage);
const encodedMessage = new TextEncoder().encode(serializedMessage);
let binaryMessage = new Uint8Array(1 + encodedMessage.length);
binaryMessage[0] = mimeType.length;
for (let i = 0; i < encodedMessage.length; i++) {
binaryMessage[i + 1] = encodedMessage[i];
}
return binaryMessage;
}
private onOpen(event: any) {
this.executeRequestsToSend();
}
private executeRequestsToSend() {
for (let requestId in this.requestsToSend) {
const request = this.requestsToSend[requestId];
this.sendGremlinMessage(request);
this.pendingRequests[request.requestId] = request;
delete this.requestsToSend[request.requestId];
}
}
private onError(err: any) {
if (this.params.failureCallback) {
this.params.failureCallback(null, err);
}
}
private onClose(event: CloseEvent) {
this.requestsToSend = {};
this.pendingRequests = {};
if (event.wasClean) {
this.params.infoCallback(`Closed connection (${event.code} ${event.reason})`);
} else {
this.params.failureCallback(null, `Unexpectedly closed connection (${event.code} ${event.reason})`);
}
}
/**
* RFC4122 version 4 compliant UUID
*/
private static uuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
private sendGremlinMessage(gremlinRequestMessage: GremlinRequestMessage) {
const gremlinFrame = GremlinSimpleClient.buildGremlinMessage(gremlinRequestMessage);
this.ws.send(gremlinFrame);
}
}