Files
hoppscotch/packages/hoppscotch-common/src/helpers/graphql/connection.ts
Anwarul Islam 88212e8cfe feat: gql revamp (#2644)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-22 17:43:43 +05:30

391 lines
9.6 KiB
TypeScript

import { GQLHeader, HoppGQLAuth, makeGQLRequest } from "@hoppscotch/data"
import { OperationType } from "@urql/core"
import {
GraphQLEnumType,
GraphQLInputObjectType,
GraphQLInterfaceType,
GraphQLObjectType,
GraphQLSchema,
buildClientSchema,
getIntrospectionQuery,
printSchema,
} from "graphql"
import { computed, reactive, ref } from "vue"
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
import { currentTabID } from "./tab"
import { getService } from "~/modules/dioc"
import { InterceptorService } from "~/services/interceptor.service"
import * as E from "fp-ts/Either"
const GQL_SCHEMA_POLL_INTERVAL = 7000
type RunQueryOptions = {
name?: string
url: string
headers: GQLHeader[]
query: string
variables: string
auth: HoppGQLAuth
operationName: string | undefined
operationType: OperationType
}
export type GQLResponseEvent = {
time: number
operationName: string | undefined
operationType: OperationType
data: string
rawQuery?: RunQueryOptions
}
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export type SubscriptionState = "SUBSCRIBING" | "SUBSCRIBED" | "UNSUBSCRIBED"
const GQL = {
CONNECTION_INIT: "connection_init",
CONNECTION_ACK: "connection_ack",
CONNECTION_ERROR: "connection_error",
CONNECTION_KEEP_ALIVE: "ka",
START: "start",
STOP: "stop",
CONNECTION_TERMINATE: "connection_terminate",
DATA: "data",
ERROR: "error",
COMPLETE: "complete",
}
type Connection = {
state: ConnectionState
subscriptionState: Map<string, SubscriptionState>
socket: WebSocket | undefined
schema: GraphQLSchema | null
}
export const connection = reactive<Connection>({
state: "DISCONNECTED",
subscriptionState: new Map<string, SubscriptionState>(),
socket: undefined,
schema: null,
})
export const schema = computed(() => connection.schema)
export const subscriptionState = computed(() => {
return connection.subscriptionState.get(currentTabID.value)
})
export const gqlMessageEvent = ref<GQLResponseEvent | "reset">()
export const schemaString = computed(() => {
if (!connection.schema) return ""
return printSchema(connection.schema, {
commentDescriptions: true,
})
})
export const queryFields = computed(() => {
if (!connection.schema) return []
const fields = connection.schema.getQueryType()?.getFields()
if (!fields) return []
return Object.values(fields)
})
export const mutationFields = computed(() => {
if (!connection.schema) return []
const fields = connection.schema.getMutationType()?.getFields()
if (!fields) return []
return Object.values(fields)
})
export const subscriptionFields = computed(() => {
if (!connection.schema) return []
const fields = connection.schema.getSubscriptionType()?.getFields()
if (!fields) return []
return Object.values(fields)
})
export const graphqlTypes = computed(() => {
if (!connection.schema) return []
const typeMap = connection.schema.getTypeMap()
const queryTypeName = connection.schema.getQueryType()?.name ?? ""
const mutationTypeName = connection.schema.getMutationType()?.name ?? ""
const subscriptionTypeName =
connection.schema.getSubscriptionType()?.name ?? ""
return Object.values(typeMap).filter((type) => {
return (
!type.name.startsWith("__") &&
![queryTypeName, mutationTypeName, subscriptionTypeName].includes(
type.name
) &&
(type instanceof GraphQLObjectType ||
type instanceof GraphQLInputObjectType ||
type instanceof GraphQLEnumType ||
type instanceof GraphQLInterfaceType)
)
})
})
let timeoutSubscription: any
export const connect = (url: string, headers: GQLHeader[]) => {
if (connection.state === "CONNECTED") {
throw new Error(
"A connection is already running. Close it before starting another."
)
}
// Polling
connection.state = "CONNECTED"
const poll = async () => {
await getSchema(url, headers)
timeoutSubscription = setTimeout(() => {
poll()
}, GQL_SCHEMA_POLL_INTERVAL)
}
poll()
}
export const disconnect = () => {
if (connection.state !== "CONNECTED") {
throw new Error("No connections are running to be disconnected")
}
clearTimeout(timeoutSubscription)
connection.state = "DISCONNECTED"
}
export const reset = () => {
if (connection.state === "CONNECTED") disconnect()
connection.state = "DISCONNECTED"
connection.schema = null
}
const getSchema = async (url: string, headers: GQLHeader[]) => {
try {
const introspectionQuery = JSON.stringify({
query: getIntrospectionQuery(),
})
const finalHeaders: Record<string, string> = {}
headers
.filter((x) => x.active && x.key !== "")
.forEach((x) => (finalHeaders[x.key] = x.value))
const reqOptions = {
method: "POST",
url,
headers: {
...finalHeaders,
"content-type": "application/json",
},
data: introspectionQuery,
}
const interceptorService = getService(InterceptorService)
const res = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(res)) {
console.error(res.left)
throw new Error(res.left.toString())
}
const data = res.right
// HACK : Temporary trailing null character issue from the extension fix
const response = new TextDecoder("utf-8")
.decode(data.data as any)
.replace(/\0+$/, "")
const introspectResponse = JSON.parse(response)
const schema = buildClientSchema(introspectResponse.data)
connection.schema = schema
} catch (e: any) {
console.error(e)
disconnect()
}
}
export const runGQLOperation = async (options: RunQueryOptions) => {
const { url, headers, query, variables, auth, operationName, operationType } =
options
const finalHeaders: Record<string, string> = {}
const parsedVariables = JSON.parse(variables || "{}")
const params: Record<string, string> = {}
if (auth.authActive) {
if (auth.authType === "basic") {
const username = auth.username
const password = auth.password
finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
finalHeaders.Authorization = `Bearer ${auth.token}`
} else if (auth.authType === "api-key") {
const { key, value, addTo } = auth
if (addTo === "Headers") {
finalHeaders[key] = value
} else if (addTo === "Query params") {
params[key] = value
}
}
}
headers
.filter((item) => item.active && item.key !== "")
.forEach(({ key, value }) => (finalHeaders[key] = value))
const reqOptions = {
method: "POST",
url,
headers: {
...finalHeaders,
"content-type": "application/json",
},
data: JSON.stringify({
query,
variables: parsedVariables,
operationName,
}),
params: {
...params,
},
}
if (operationType === "subscription") {
return runSubscription(options)
}
const interceptorService = getService(InterceptorService)
const result = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(result.left.toString())
}
const res = result.right
// HACK: Temporary trailing null character issue from the extension fix
const responseText = new TextDecoder("utf-8")
.decode(res.data as any)
.replace(/\0+$/, "")
gqlMessageEvent.value = {
time: Date.now(),
operationName: operationName ?? "query",
data: responseText,
rawQuery: options,
operationType,
}
addQueryToHistory(options, responseText)
return responseText
}
export const runSubscription = (options: RunQueryOptions) => {
const { url, query, operationName } = options
const wsUrl = url.replace(/^http/, "ws")
connection.subscriptionState.set(currentTabID.value, "SUBSCRIBING")
connection.socket = new WebSocket(wsUrl, "graphql-ws")
connection.socket.onopen = (event) => {
console.log("WebSocket is open now.", event)
connection.socket?.send(
JSON.stringify({
type: GQL.CONNECTION_INIT,
payload: {},
})
)
connection.socket?.send(
JSON.stringify({
type: GQL.START,
id: "1",
payload: { query, operationName },
})
)
}
gqlMessageEvent.value = "reset"
connection.socket.onmessage = (event) => {
const data = JSON.parse(event.data)
switch (data.type) {
case GQL.CONNECTION_ACK: {
connection.subscriptionState.set(currentTabID.value, "SUBSCRIBED")
break
}
case GQL.CONNECTION_ERROR: {
console.error(data.payload)
break
}
case GQL.CONNECTION_KEEP_ALIVE: {
break
}
case GQL.DATA: {
gqlMessageEvent.value = {
time: Date.now(),
operationName,
data: JSON.stringify(data.payload),
operationType: "subscription",
}
break
}
case GQL.COMPLETE: {
console.log("completed", data.id)
break
}
}
}
connection.socket.onclose = (event) => {
console.log("WebSocket is closed now.", event)
connection.subscriptionState.set(currentTabID.value, "UNSUBSCRIBED")
}
addQueryToHistory(options, "")
return connection.socket
}
export const socketDisconnect = () => {
connection.socket?.close()
}
const addQueryToHistory = (options: RunQueryOptions, response: string) => {
const { name, url, headers, query, variables, auth } = options
addGraphqlHistoryEntry(
makeGQLHistoryEntry({
request: makeGQLRequest({
name: name ?? "Untitled Request",
url,
query,
headers,
variables,
auth,
}),
response,
star: false,
})
)
}