273 lines
6.9 KiB
TypeScript
273 lines
6.9 KiB
TypeScript
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 { sendNetworkRequest } from "./network"
|
|
|
|
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, {
|
|
commentDescriptions: true,
|
|
})
|
|
})
|
|
)
|
|
|
|
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",
|
|
url,
|
|
headers: {
|
|
...finalHeaders,
|
|
"content-type": "application/json",
|
|
},
|
|
data: introspectionQuery,
|
|
}
|
|
|
|
const data = await sendNetworkRequest(reqOptions)
|
|
|
|
// HACK : Temporary trailing null character issue from the extension fix
|
|
const response = new TextDecoder("utf-8")
|
|
.decode(data.data)
|
|
.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",
|
|
url,
|
|
headers: {
|
|
...finalHeaders,
|
|
"content-type": "application/json",
|
|
},
|
|
data: JSON.stringify({
|
|
query,
|
|
variables: parsedVariables,
|
|
}),
|
|
params: {
|
|
...params,
|
|
},
|
|
}
|
|
|
|
const res = await sendNetworkRequest(reqOptions)
|
|
|
|
// HACK: Temporary trailing null character issue from the extension fix
|
|
const responseText = new TextDecoder("utf-8")
|
|
.decode(res.data)
|
|
.replace(/\0+$/, "")
|
|
|
|
return responseText
|
|
}
|
|
}
|