connection.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. import { GQLHeader, HoppGQLAuth, makeGQLRequest } from "@hoppscotch/data"
  2. import { OperationType } from "@urql/core"
  3. import { AwsV4Signer } from "aws4fetch"
  4. import * as E from "fp-ts/Either"
  5. import {
  6. GraphQLEnumType,
  7. GraphQLInputObjectType,
  8. GraphQLInterfaceType,
  9. GraphQLObjectType,
  10. GraphQLSchema,
  11. buildClientSchema,
  12. getIntrospectionQuery,
  13. printSchema,
  14. } from "graphql"
  15. import { Component, computed, reactive, ref } from "vue"
  16. import { getService } from "~/modules/dioc"
  17. import { getI18n } from "~/modules/i18n"
  18. import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
  19. import { InterceptorService } from "~/services/interceptor.service"
  20. import { GQLTabService } from "~/services/tab/graphql"
  21. const GQL_SCHEMA_POLL_INTERVAL = 7000
  22. type RunQueryOptions = {
  23. name?: string
  24. url: string
  25. headers: GQLHeader[]
  26. query: string
  27. variables: string
  28. auth: HoppGQLAuth
  29. operationName: string | undefined
  30. operationType: OperationType
  31. }
  32. export type GQLResponseEvent =
  33. | {
  34. type: "response"
  35. time: number
  36. operationName: string | undefined
  37. operationType: OperationType
  38. data: string
  39. rawQuery?: RunQueryOptions
  40. }
  41. | {
  42. type: "error"
  43. error: {
  44. type: string
  45. message: string
  46. component?: Component
  47. }
  48. }
  49. export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
  50. export type SubscriptionState = "SUBSCRIBING" | "SUBSCRIBED" | "UNSUBSCRIBED"
  51. const GQL = {
  52. CONNECTION_INIT: "connection_init",
  53. CONNECTION_ACK: "connection_ack",
  54. CONNECTION_ERROR: "connection_error",
  55. CONNECTION_KEEP_ALIVE: "ka",
  56. START: "start",
  57. STOP: "stop",
  58. CONNECTION_TERMINATE: "connection_terminate",
  59. DATA: "data",
  60. ERROR: "error",
  61. COMPLETE: "complete",
  62. }
  63. type Connection = {
  64. state: ConnectionState
  65. subscriptionState: Map<string, SubscriptionState>
  66. socket: WebSocket | undefined
  67. schema: GraphQLSchema | null
  68. error?: {
  69. type: string
  70. message: (t: ReturnType<typeof getI18n>) => string
  71. component?: Component
  72. } | null
  73. }
  74. const tabs = getService(GQLTabService)
  75. const currentTabID = computed(() => tabs.currentTabID.value)
  76. export const connection = reactive<Connection>({
  77. state: "DISCONNECTED",
  78. subscriptionState: new Map<string, SubscriptionState>(),
  79. socket: undefined,
  80. schema: null,
  81. error: null,
  82. })
  83. export const schema = computed(() => connection.schema)
  84. export const subscriptionState = computed(() => {
  85. return connection.subscriptionState.get(currentTabID.value)
  86. })
  87. export const gqlMessageEvent = ref<GQLResponseEvent | "reset">()
  88. export const schemaString = computed(() => {
  89. if (!connection.schema) return ""
  90. return printSchema(connection.schema, {
  91. commentDescriptions: true,
  92. })
  93. })
  94. export const queryFields = computed(() => {
  95. if (!connection.schema) return []
  96. const fields = connection.schema.getQueryType()?.getFields()
  97. if (!fields) return []
  98. return Object.values(fields)
  99. })
  100. export const mutationFields = computed(() => {
  101. if (!connection.schema) return []
  102. const fields = connection.schema.getMutationType()?.getFields()
  103. if (!fields) return []
  104. return Object.values(fields)
  105. })
  106. export const subscriptionFields = computed(() => {
  107. if (!connection.schema) return []
  108. const fields = connection.schema.getSubscriptionType()?.getFields()
  109. if (!fields) return []
  110. return Object.values(fields)
  111. })
  112. export const graphqlTypes = computed(() => {
  113. if (!connection.schema) return []
  114. const typeMap = connection.schema.getTypeMap()
  115. const queryTypeName = connection.schema.getQueryType()?.name ?? ""
  116. const mutationTypeName = connection.schema.getMutationType()?.name ?? ""
  117. const subscriptionTypeName =
  118. connection.schema.getSubscriptionType()?.name ?? ""
  119. return Object.values(typeMap).filter((type) => {
  120. return (
  121. !type.name.startsWith("__") &&
  122. ![queryTypeName, mutationTypeName, subscriptionTypeName].includes(
  123. type.name
  124. ) &&
  125. (type instanceof GraphQLObjectType ||
  126. type instanceof GraphQLInputObjectType ||
  127. type instanceof GraphQLEnumType ||
  128. type instanceof GraphQLInterfaceType)
  129. )
  130. })
  131. })
  132. let timeoutSubscription: any
  133. export const connect = async (url: string, headers: GQLHeader[]) => {
  134. if (connection.state === "CONNECTED") {
  135. throw new Error(
  136. "A connection is already running. Close it before starting another."
  137. )
  138. }
  139. // Polling
  140. connection.state = "CONNECTED"
  141. const poll = async () => {
  142. await getSchema(url, headers)
  143. timeoutSubscription = setTimeout(() => {
  144. poll()
  145. }, GQL_SCHEMA_POLL_INTERVAL)
  146. }
  147. await poll()
  148. }
  149. export const disconnect = () => {
  150. if (connection.state !== "CONNECTED") {
  151. throw new Error("No connections are running to be disconnected")
  152. }
  153. clearTimeout(timeoutSubscription)
  154. connection.state = "DISCONNECTED"
  155. }
  156. export const reset = () => {
  157. if (connection.state === "CONNECTED") disconnect()
  158. connection.state = "DISCONNECTED"
  159. connection.schema = null
  160. }
  161. const getSchema = async (url: string, headers: GQLHeader[]) => {
  162. try {
  163. const introspectionQuery = JSON.stringify({
  164. query: getIntrospectionQuery(),
  165. })
  166. const finalHeaders: Record<string, string> = {}
  167. headers
  168. .filter((x) => x.active && x.key !== "")
  169. .forEach((x) => (finalHeaders[x.key] = x.value))
  170. const reqOptions = {
  171. method: "POST",
  172. url,
  173. headers: {
  174. ...finalHeaders,
  175. "content-type": "application/json",
  176. },
  177. data: introspectionQuery,
  178. }
  179. const interceptorService = getService(InterceptorService)
  180. const res = await interceptorService.runRequest(reqOptions).response
  181. if (E.isLeft(res)) {
  182. if (
  183. res.left !== "cancellation" &&
  184. res.left.error === "NO_PW_EXT_HOOK" &&
  185. res.left.humanMessage
  186. ) {
  187. connection.error = {
  188. type: res.left.error,
  189. message: (t: ReturnType<typeof getI18n>) =>
  190. res.left.humanMessage.description(t),
  191. component: res.left.component,
  192. }
  193. }
  194. throw new Error(res.left.toString())
  195. }
  196. const data = res.right
  197. // HACK : Temporary trailing null character issue from the extension fix
  198. const response = new TextDecoder("utf-8")
  199. .decode(data.data as any)
  200. .replace(/\0+$/, "")
  201. const introspectResponse = JSON.parse(response)
  202. const schema = buildClientSchema(introspectResponse.data)
  203. connection.schema = schema
  204. connection.error = null
  205. } catch (e: any) {
  206. console.error(e)
  207. disconnect()
  208. }
  209. }
  210. export const runGQLOperation = async (options: RunQueryOptions) => {
  211. if (connection.state !== "CONNECTED") {
  212. await connect(options.url, options.headers)
  213. }
  214. const { url, headers, query, variables, auth, operationName, operationType } =
  215. options
  216. const finalHeaders: Record<string, string> = {}
  217. const parsedVariables = JSON.parse(variables || "{}")
  218. const params: Record<string, string> = {}
  219. if (auth.authActive) {
  220. if (auth.authType === "basic") {
  221. const username = auth.username
  222. const password = auth.password
  223. finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`
  224. } else if (auth.authType === "bearer") {
  225. finalHeaders.Authorization = `Bearer ${auth.token}`
  226. } else if (auth.authType === "oauth-2") {
  227. const { addTo } = auth
  228. if (addTo === "HEADERS") {
  229. finalHeaders.Authorization = `Bearer ${auth.grantTypeInfo.token}`
  230. } else if (addTo === "QUERY_PARAMS") {
  231. params["access_token"] = auth.grantTypeInfo.token
  232. }
  233. } else if (auth.authType === "api-key") {
  234. const { key, value, addTo } = auth
  235. if (addTo === "HEADERS") {
  236. finalHeaders[key] = value
  237. } else if (addTo === "QUERY_PARAMS") {
  238. params[key] = value
  239. }
  240. } else if (auth.authType === "aws-signature") {
  241. const { accessKey, secretKey, region, serviceName, addTo, serviceToken } =
  242. auth
  243. const currentDate = new Date()
  244. const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "")
  245. const signer = new AwsV4Signer({
  246. datetime: amzDate,
  247. signQuery: addTo === "QUERY_PARAMS",
  248. accessKeyId: accessKey,
  249. secretAccessKey: secretKey,
  250. region: region ?? "us-east-1",
  251. service: serviceName,
  252. url,
  253. sessionToken: serviceToken,
  254. })
  255. const sign = await signer.sign()
  256. if (addTo === "HEADERS") {
  257. sign.headers.forEach((v, k) => {
  258. finalHeaders[k] = v
  259. })
  260. } else if (addTo === "QUERY_PARAMS") {
  261. for (const [k, v] of sign.url.searchParams) {
  262. params[k] = v
  263. }
  264. }
  265. }
  266. }
  267. headers
  268. .filter((item) => item.active && item.key !== "")
  269. .forEach(({ key, value }) => (finalHeaders[key] = value))
  270. const reqOptions = {
  271. method: "POST",
  272. url,
  273. headers: {
  274. ...finalHeaders,
  275. "content-type": "application/json",
  276. },
  277. data: JSON.stringify({
  278. query,
  279. variables: parsedVariables,
  280. operationName,
  281. }),
  282. params: {
  283. ...params,
  284. },
  285. }
  286. if (operationType === "subscription") {
  287. return runSubscription(options, finalHeaders)
  288. }
  289. const interceptorService = getService(InterceptorService)
  290. const result = await interceptorService.runRequest(reqOptions).response
  291. if (E.isLeft(result)) {
  292. if (
  293. result.left !== "cancellation" &&
  294. result.left.error === "NO_PW_EXT_HOOK" &&
  295. result.left.humanMessage
  296. ) {
  297. connection.error = {
  298. type: result.left.error,
  299. message: (t: ReturnType<typeof getI18n>) =>
  300. result.left.humanMessage.description(t),
  301. component: result.left.component,
  302. }
  303. }
  304. throw new Error(result.left.toString())
  305. }
  306. const res = result.right
  307. // HACK: Temporary trailing null character issue from the extension fix
  308. const responseText = new TextDecoder("utf-8")
  309. .decode(res.data as any)
  310. .replace(/\0+$/, "")
  311. gqlMessageEvent.value = {
  312. type: "response",
  313. time: Date.now(),
  314. operationName: operationName ?? "query",
  315. data: responseText,
  316. rawQuery: options,
  317. operationType,
  318. }
  319. addQueryToHistory(options, responseText)
  320. return responseText
  321. }
  322. export const runSubscription = (
  323. options: RunQueryOptions,
  324. headers?: Record<string, string>
  325. ) => {
  326. const { url, query, operationName } = options
  327. const wsUrl = url.replace(/^http/, "ws")
  328. connection.subscriptionState.set(currentTabID.value, "SUBSCRIBING")
  329. connection.socket = new WebSocket(wsUrl, "graphql-ws")
  330. connection.socket.onopen = (event) => {
  331. console.log("WebSocket is open now.", event)
  332. connection.socket?.send(
  333. JSON.stringify({
  334. type: GQL.CONNECTION_INIT,
  335. payload: headers ?? {},
  336. })
  337. )
  338. connection.socket?.send(
  339. JSON.stringify({
  340. type: GQL.START,
  341. id: "1",
  342. payload: { query, operationName },
  343. })
  344. )
  345. }
  346. gqlMessageEvent.value = "reset"
  347. connection.socket.onmessage = (event) => {
  348. const data = JSON.parse(event.data)
  349. switch (data.type) {
  350. case GQL.CONNECTION_ACK: {
  351. connection.subscriptionState.set(currentTabID.value, "SUBSCRIBED")
  352. break
  353. }
  354. case GQL.CONNECTION_ERROR: {
  355. console.error(data.payload)
  356. break
  357. }
  358. case GQL.CONNECTION_KEEP_ALIVE: {
  359. break
  360. }
  361. case GQL.DATA: {
  362. gqlMessageEvent.value = {
  363. type: "response",
  364. time: Date.now(),
  365. operationName,
  366. data: JSON.stringify(data.payload),
  367. operationType: "subscription",
  368. }
  369. break
  370. }
  371. case GQL.COMPLETE: {
  372. console.log("completed", data.id)
  373. break
  374. }
  375. }
  376. }
  377. connection.socket.onclose = (event) => {
  378. console.log("WebSocket is closed now.", event)
  379. connection.subscriptionState.set(currentTabID.value, "UNSUBSCRIBED")
  380. }
  381. addQueryToHistory(options, "")
  382. return connection.socket
  383. }
  384. export const socketDisconnect = () => {
  385. connection.socket?.close()
  386. }
  387. const addQueryToHistory = (options: RunQueryOptions, response: string) => {
  388. const { name, url, headers, query, variables, auth } = options
  389. addGraphqlHistoryEntry(
  390. makeGQLHistoryEntry({
  391. request: makeGQLRequest({
  392. name: name ?? "Untitled Request",
  393. url,
  394. query,
  395. headers,
  396. variables,
  397. auth,
  398. }),
  399. response,
  400. star: false,
  401. })
  402. )
  403. }