feat: gql revamp (#2644)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com> Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
@@ -1,289 +0,0 @@
|
||||
import * as E from "fp-ts/Either"
|
||||
import { BehaviorSubject } from "rxjs"
|
||||
import {
|
||||
getIntrospectionQuery,
|
||||
buildClientSchema,
|
||||
GraphQLSchema,
|
||||
printSchema,
|
||||
GraphQLObjectType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLEnumType,
|
||||
GraphQLInterfaceType,
|
||||
} from "graphql"
|
||||
import { distinctUntilChanged, map } from "rxjs/operators"
|
||||
import { GQLHeader, HoppGQLAuth } from "@hoppscotch/data"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
|
||||
const GQL_SCHEMA_POLL_INTERVAL = 7000
|
||||
|
||||
/**
|
||||
GQLConnection deals with all the operations (like polling, schema extraction) that runs
|
||||
when a connection is made to a GraphQL server.
|
||||
*/
|
||||
export class GQLConnection {
|
||||
public isLoading$ = new BehaviorSubject<boolean>(false)
|
||||
public connected$ = new BehaviorSubject<boolean>(false)
|
||||
public schema$ = new BehaviorSubject<GraphQLSchema | null>(null)
|
||||
|
||||
public schemaString$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
return printSchema(schema)
|
||||
})
|
||||
)
|
||||
|
||||
public queryFields$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const fields = schema.getQueryType()?.getFields()
|
||||
if (!fields) return null
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
)
|
||||
|
||||
public mutationFields$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const fields = schema.getMutationType()?.getFields()
|
||||
if (!fields) return null
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
)
|
||||
|
||||
public subscriptionFields$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const fields = schema.getSubscriptionType()?.getFields()
|
||||
if (!fields) return null
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
)
|
||||
|
||||
public graphqlTypes$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const typeMap = schema.getTypeMap()
|
||||
|
||||
const queryTypeName = schema.getQueryType()?.name ?? ""
|
||||
const mutationTypeName = schema.getMutationType()?.name ?? ""
|
||||
const subscriptionTypeName = 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
private timeoutSubscription: any
|
||||
|
||||
public connect(url: string, headers: GQLHeader[], auth: HoppGQLAuth) {
|
||||
if (this.connected$.value) {
|
||||
throw new Error(
|
||||
"A connection is already running. Close it before starting another."
|
||||
)
|
||||
}
|
||||
|
||||
// Polling
|
||||
this.connected$.next(true)
|
||||
|
||||
const poll = async () => {
|
||||
await this.getSchema(url, headers, auth)
|
||||
this.timeoutSubscription = setTimeout(() => {
|
||||
poll()
|
||||
}, GQL_SCHEMA_POLL_INTERVAL)
|
||||
}
|
||||
poll()
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
if (!this.connected$.value) {
|
||||
throw new Error("No connections are running to be disconnected")
|
||||
}
|
||||
|
||||
clearTimeout(this.timeoutSubscription)
|
||||
this.connected$.next(false)
|
||||
}
|
||||
|
||||
public reset() {
|
||||
if (this.connected$.value) this.disconnect()
|
||||
|
||||
this.isLoading$.next(false)
|
||||
this.connected$.next(false)
|
||||
this.schema$.next(null)
|
||||
}
|
||||
|
||||
private async getSchema(
|
||||
url: string,
|
||||
reqHeaders: GQLHeader[],
|
||||
auth: HoppGQLAuth
|
||||
) {
|
||||
try {
|
||||
this.isLoading$.next(true)
|
||||
|
||||
const introspectionQuery = JSON.stringify({
|
||||
query: getIntrospectionQuery(),
|
||||
})
|
||||
|
||||
const headers = reqHeaders.filter((x) => x.active && x.key !== "")
|
||||
|
||||
// TODO: Support a better b64 implementation than btoa ?
|
||||
if (auth.authType === "basic") {
|
||||
const username = auth.username
|
||||
const password = auth.password
|
||||
|
||||
headers.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Basic ${btoa(`${username}:${password}`)}`,
|
||||
})
|
||||
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
|
||||
headers.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${auth.token}`,
|
||||
})
|
||||
} else if (auth.authType === "api-key") {
|
||||
const { key, value, addTo } = auth
|
||||
|
||||
if (addTo === "Headers") {
|
||||
headers.push({
|
||||
active: true,
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const finalHeaders: Record<string, string> = {}
|
||||
headers.forEach((x) => (finalHeaders[x.key] = x.value))
|
||||
|
||||
const reqOptions = {
|
||||
method: "POST" as const,
|
||||
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)
|
||||
|
||||
this.schema$.next(schema)
|
||||
|
||||
this.isLoading$.next(false)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
this.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public async runQuery(
|
||||
url: string,
|
||||
headers: GQLHeader[],
|
||||
query: string,
|
||||
variables: string,
|
||||
auth: HoppGQLAuth
|
||||
) {
|
||||
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" as const,
|
||||
url,
|
||||
headers: {
|
||||
...finalHeaders,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
data: JSON.stringify({
|
||||
query,
|
||||
variables: parsedVariables,
|
||||
}),
|
||||
params: {
|
||||
...params,
|
||||
},
|
||||
}
|
||||
|
||||
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+$/, "")
|
||||
|
||||
return responseText
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export type HoppAction =
|
||||
| "history.clear" // Clear REST History
|
||||
| "user.login" // Login to Hoppscotch
|
||||
| "user.logout" // Log out of Hoppscotch
|
||||
| "editor.format" // Format editor content
|
||||
|
||||
/**
|
||||
* Defines the arguments, if present for a given type that is required to be passed on
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EditorState, Range } from "@codemirror/state"
|
||||
import { Decoration, ViewPlugin } from "@codemirror/view"
|
||||
import { syntaxTree } from "@codemirror/language"
|
||||
|
||||
function getOperationDefsPosInEditor(state: EditorState) {
|
||||
const tree = syntaxTree(state)
|
||||
|
||||
const defs: Array<{ from: number; to: number }> = []
|
||||
|
||||
tree.iterate({
|
||||
enter({ name, from, to }) {
|
||||
if (name === "OperationDefinition") {
|
||||
defs.push({ from, to })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return defs
|
||||
}
|
||||
|
||||
function generateSelectedOpDecors(state: EditorState) {
|
||||
const selectedPos = state.selection.main.head // Cursor Pos
|
||||
|
||||
const defsPositions = getOperationDefsPosInEditor(state)
|
||||
|
||||
if (defsPositions.length === 1) return Decoration.none
|
||||
|
||||
const decors = defsPositions
|
||||
.map(({ from, to }) => ({
|
||||
selected: selectedPos >= from && selectedPos <= to,
|
||||
from,
|
||||
to,
|
||||
}))
|
||||
.map((info) => ({
|
||||
...info,
|
||||
decor: Decoration.mark({
|
||||
class: info.selected
|
||||
? "gql-operation-highlight"
|
||||
: "gql-operation-not-highlight",
|
||||
inclusive: true,
|
||||
}),
|
||||
}))
|
||||
.map(({ from, to, decor }) => <Range<Decoration>>{ from, to, value: decor }) // Convert to Range<Decoration> (Range from "@codemirror/view")
|
||||
|
||||
return Decoration.set(decors)
|
||||
}
|
||||
|
||||
export const selectedGQLOpHighlight = ViewPlugin.define(
|
||||
(view) => ({
|
||||
decorations: generateSelectedOpDecors(view.state),
|
||||
update(u) {
|
||||
this.decorations = generateSelectedOpDecors(u.state)
|
||||
},
|
||||
}),
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
)
|
||||
390
packages/hoppscotch-common/src/helpers/graphql/connection.ts
Normal file
390
packages/hoppscotch-common/src/helpers/graphql/connection.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
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,
|
||||
})
|
||||
)
|
||||
}
|
||||
33
packages/hoppscotch-common/src/helpers/graphql/default.ts
Normal file
33
packages/hoppscotch-common/src/helpers/graphql/default.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { parse, print } from "graphql"
|
||||
import { HoppGQLRequest, GQL_REQ_SCHEMA_VERSION } from "@hoppscotch/data"
|
||||
|
||||
const DEFAULT_QUERY = print(
|
||||
parse(
|
||||
`
|
||||
query Request {
|
||||
method
|
||||
url
|
||||
headers {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ allowLegacyFragmentVariables: true }
|
||||
)
|
||||
)
|
||||
|
||||
export const getDefaultGQLRequest = (): HoppGQLRequest => ({
|
||||
v: GQL_REQ_SCHEMA_VERSION,
|
||||
name: "Untitled",
|
||||
url: "https://echo.hoppscotch.io/graphql",
|
||||
headers: [],
|
||||
variables: `{
|
||||
"id": "1"
|
||||
}`,
|
||||
query: DEFAULT_QUERY,
|
||||
auth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
})
|
||||
58
packages/hoppscotch-common/src/helpers/graphql/document.ts
Normal file
58
packages/hoppscotch-common/src/helpers/graphql/document.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { HoppGQLRequest } from "@hoppscotch/data"
|
||||
|
||||
export type HoppGQLSaveContext =
|
||||
| {
|
||||
/**
|
||||
* The origin source of the request
|
||||
*/
|
||||
originLocation: "user-collection"
|
||||
/**
|
||||
* Path to the request folder
|
||||
*/
|
||||
folderPath: string
|
||||
/**
|
||||
* Index to the request
|
||||
*/
|
||||
requestIndex: number
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* The origin source of the request
|
||||
*/
|
||||
originLocation: "team-collection"
|
||||
/**
|
||||
* ID of the request in the team
|
||||
*/
|
||||
requestID: string
|
||||
/**
|
||||
* ID of the team
|
||||
*/
|
||||
teamID?: string
|
||||
/**
|
||||
* ID of the collection loaded
|
||||
*/
|
||||
collectionID?: string
|
||||
}
|
||||
| null
|
||||
|
||||
/**
|
||||
* Defines a live 'document' (something that is open and being edited) in the app
|
||||
*/
|
||||
export type HoppGQLDocument = {
|
||||
/**
|
||||
* The request as it is in the document
|
||||
*/
|
||||
request: HoppGQLRequest
|
||||
|
||||
/**
|
||||
* Whether the request has any unsaved changes
|
||||
* (atleast as far as we can say)
|
||||
*/
|
||||
isDirty: boolean
|
||||
|
||||
/**
|
||||
* Info about where this request should be saved.
|
||||
* This contains where the request is originated from basically.
|
||||
*/
|
||||
saveContext?: HoppGQLSaveContext
|
||||
}
|
||||
52
packages/hoppscotch-common/src/helpers/graphql/eq.ts
Normal file
52
packages/hoppscotch-common/src/helpers/graphql/eq.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as Eq from "fp-ts/Eq"
|
||||
import * as S from "fp-ts/string"
|
||||
import isEqual from "lodash-es/isEqual"
|
||||
|
||||
/*
|
||||
* Eq-s are fp-ts an interface (type class) that defines how the equality
|
||||
* of 2 values of a certain type are matched as equal
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create an Eq from a non-undefinable value and makes it accept undefined
|
||||
* @param eq The non nullable Eq to add to
|
||||
* @returns The updated Eq which accepts undefined
|
||||
*/
|
||||
export const undefinedEq = <T>(eq: Eq.Eq<T>): Eq.Eq<T | undefined> => ({
|
||||
equals(x: T | undefined, y: T | undefined) {
|
||||
if (x !== undefined && y !== undefined) {
|
||||
return eq.equals(x, y)
|
||||
}
|
||||
|
||||
return x === undefined && y === undefined
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* An Eq which compares by transforming based on a mapping function and then applying the Eq to it
|
||||
* @param map The mapping function to map values to
|
||||
* @param eq The Eq which takes the value which the map returns
|
||||
* @returns An Eq which takes the input of the mapping function
|
||||
*/
|
||||
export const mapThenEq = <A, B>(map: (x: A) => B, eq: Eq.Eq<B>): Eq.Eq<A> => ({
|
||||
equals(x: A, y: A) {
|
||||
return eq.equals(map(x), map(y))
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* An Eq which checks equality of 2 string in a case insensitive way
|
||||
*/
|
||||
export const stringCaseInsensitiveEq: Eq.Eq<string> = mapThenEq(
|
||||
S.toLowerCase,
|
||||
S.Eq
|
||||
)
|
||||
|
||||
/**
|
||||
* An Eq that does equality check with Lodash's isEqual function
|
||||
*/
|
||||
export const lodashIsEqualEq: Eq.Eq<any> = {
|
||||
equals(x: any, y: any) {
|
||||
return isEqual(x, y)
|
||||
},
|
||||
}
|
||||
54
packages/hoppscotch-common/src/helpers/graphql/index.ts
Normal file
54
packages/hoppscotch-common/src/helpers/graphql/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { HoppGQLRequest, ValidContentTypes } from "@hoppscotch/data"
|
||||
import * as Eq from "fp-ts/Eq"
|
||||
import * as N from "fp-ts/number"
|
||||
import * as S from "fp-ts/string"
|
||||
import { lodashIsEqualEq, mapThenEq, undefinedEq } from "./eq"
|
||||
|
||||
export type HoppGQLParam = {
|
||||
key: string
|
||||
value: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type HoppGQLHeader = {
|
||||
key: string
|
||||
value: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type FormDataKeyValue = {
|
||||
key: string
|
||||
active: boolean
|
||||
} & ({ isFile: true; value: Blob[] } | { isFile: false; value: string })
|
||||
|
||||
export type HoppGQLReqBodyFormData = {
|
||||
contentType: "multipart/form-data"
|
||||
body: FormDataKeyValue[]
|
||||
}
|
||||
|
||||
export type HoppGQLReqBody =
|
||||
| {
|
||||
contentType: Exclude<ValidContentTypes, "multipart/form-data">
|
||||
body: string
|
||||
}
|
||||
| HoppGQLReqBodyFormData
|
||||
| {
|
||||
contentType: null
|
||||
body: null
|
||||
}
|
||||
|
||||
export const HoppGQLRequestEq = Eq.struct<HoppGQLRequest>({
|
||||
id: undefinedEq(S.Eq),
|
||||
v: N.Eq,
|
||||
name: S.Eq,
|
||||
url: S.Eq,
|
||||
headers: mapThenEq(
|
||||
(arr) => arr.filter((h) => h.key !== "" && h.value !== ""),
|
||||
lodashIsEqualEq
|
||||
),
|
||||
query: S.Eq,
|
||||
variables: S.Eq,
|
||||
auth: lodashIsEqualEq,
|
||||
})
|
||||
|
||||
export const isEqualHoppGQLRequest = HoppGQLRequestEq.equals
|
||||
199
packages/hoppscotch-common/src/helpers/graphql/tab.ts
Normal file
199
packages/hoppscotch-common/src/helpers/graphql/tab.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { refWithControl } from "@vueuse/core"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { v4 as uuidV4 } from "uuid"
|
||||
import { computed, reactive, ref, shallowReadonly, watch } from "vue"
|
||||
import { HoppTestResult } from "../types/HoppTestResult"
|
||||
import { GQLResponseEvent } from "./connection"
|
||||
import { getDefaultGQLRequest } from "./default"
|
||||
import { HoppGQLDocument, HoppGQLSaveContext } from "./document"
|
||||
|
||||
export type HoppGQLTab = {
|
||||
id: string
|
||||
document: HoppGQLDocument
|
||||
response?: GQLResponseEvent[] | null
|
||||
testResults?: HoppTestResult | null
|
||||
}
|
||||
|
||||
export type PersistableGQLTabState = {
|
||||
lastActiveTabID: string
|
||||
orderedDocs: Array<{
|
||||
tabID: string
|
||||
doc: HoppGQLDocument
|
||||
}>
|
||||
}
|
||||
|
||||
export const currentTabID = refWithControl("test", {
|
||||
onBeforeChange(newTabID) {
|
||||
if (!newTabID || !tabMap.has(newTabID)) {
|
||||
console.warn(
|
||||
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
|
||||
)
|
||||
|
||||
// Don't allow change
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const tabMap = reactive(
|
||||
new Map<string, HoppGQLTab>([
|
||||
[
|
||||
"test",
|
||||
{
|
||||
id: "test",
|
||||
document: {
|
||||
request: getDefaultGQLRequest(),
|
||||
isDirty: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
const tabOrdering = ref<string[]>(["test"])
|
||||
|
||||
watch(
|
||||
tabOrdering,
|
||||
(newOrdering) => {
|
||||
if (!currentTabID.value || !newOrdering.includes(currentTabID.value)) {
|
||||
currentTabID.value = newOrdering[newOrdering.length - 1] // newOrdering should always be non-empty
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
export const persistableTabState = computed<PersistableGQLTabState>(() => ({
|
||||
lastActiveTabID: currentTabID.value,
|
||||
orderedDocs: tabOrdering.value.map((tabID) => {
|
||||
const tab = tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
|
||||
return {
|
||||
tabID: tab.id,
|
||||
doc: tab.document,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
export const currentActiveTab = computed(() => tabMap.get(currentTabID.value)!) // Guaranteed to not be undefined
|
||||
|
||||
// TODO: Mark this unknown and do validations
|
||||
export function loadTabsFromPersistedState(data: PersistableGQLTabState) {
|
||||
if (data) {
|
||||
tabMap.clear()
|
||||
tabOrdering.value = []
|
||||
|
||||
for (const doc of data.orderedDocs) {
|
||||
tabMap.set(doc.tabID, {
|
||||
id: doc.tabID,
|
||||
document: doc.doc,
|
||||
})
|
||||
|
||||
tabOrdering.value.push(doc.tabID)
|
||||
}
|
||||
|
||||
currentTabID.value = data.lastActiveTabID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the active Tab IDs in order
|
||||
*/
|
||||
export function getActiveTabs() {
|
||||
return shallowReadonly(
|
||||
computed(() => tabOrdering.value.map((x) => tabMap.get(x)!))
|
||||
)
|
||||
}
|
||||
|
||||
export function getTabRef(tabID: string) {
|
||||
return computed({
|
||||
get() {
|
||||
const result = tabMap.get(tabID)
|
||||
|
||||
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
|
||||
|
||||
return result
|
||||
},
|
||||
set(value) {
|
||||
return tabMap.set(tabID, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function generateNewTabID() {
|
||||
while (true) {
|
||||
const id = uuidV4()
|
||||
|
||||
if (!tabMap.has(id)) return id
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTab(tabUpdate: HoppGQLTab) {
|
||||
if (!tabMap.has(tabUpdate.id)) {
|
||||
console.warn(
|
||||
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
|
||||
)
|
||||
}
|
||||
|
||||
tabMap.set(tabUpdate.id, tabUpdate)
|
||||
}
|
||||
|
||||
export function createNewTab(document: HoppGQLDocument, switchToIt = true) {
|
||||
const id = generateNewTabID()
|
||||
|
||||
const tab: HoppGQLTab = { id, document }
|
||||
|
||||
tabMap.set(id, tab)
|
||||
tabOrdering.value.push(id)
|
||||
|
||||
if (switchToIt) {
|
||||
currentTabID.value = id
|
||||
}
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
export function updateTabOrdering(fromIndex: number, toIndex: number) {
|
||||
tabOrdering.value.splice(
|
||||
toIndex,
|
||||
0,
|
||||
tabOrdering.value.splice(fromIndex, 1)[0]
|
||||
)
|
||||
}
|
||||
|
||||
export function closeTab(tabID: string) {
|
||||
if (!tabMap.has(tabID)) {
|
||||
console.warn(`Tried to close a tab which does not exist (tab id: ${tabID})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (tabOrdering.value.length === 1) {
|
||||
console.warn(
|
||||
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1)
|
||||
|
||||
tabMap.delete(tabID)
|
||||
}
|
||||
|
||||
export function getTabRefWithSaveContext(ctx: HoppGQLSaveContext) {
|
||||
for (const tab of tabMap.values()) {
|
||||
// For `team-collection` request id can be considered unique
|
||||
if (ctx && ctx.originLocation === "team-collection") {
|
||||
if (
|
||||
tab.document.saveContext?.originLocation === "team-collection" &&
|
||||
tab.document.saveContext.requestID === ctx.requestID
|
||||
) {
|
||||
return getTabRef(tab.id)
|
||||
}
|
||||
} else if (isEqual(ctx, tab.document.saveContext)) return getTabRef(tab.id)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getTabsRefTo(func: (tab: HoppGQLTab) => boolean) {
|
||||
return Array.from(tabMap.values())
|
||||
.filter(func)
|
||||
.map((tab) => getTabRef(tab.id))
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export const bindings: {
|
||||
"ctrl-shift-p": "response.preview.toggle",
|
||||
"ctrl-j": "response.file.download",
|
||||
"ctrl-.": "response.copy",
|
||||
"ctrl-shift-l": "editor.format",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user