Add an authorization tab for GraphQL (#2125)

Co-authored-by: Rishabh Agarwal <rishabh2001agarwal@gmail.com>
Co-authored-by: Rishabh Agarwal <45998880+RishabhAgarwal-2001@users.noreply.github.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Liyas Thomas
2022-03-15 00:44:26 +05:30
committed by GitHub
parent dcdd0379d4
commit 715d910877
12 changed files with 491 additions and 29 deletions

View File

@@ -123,6 +123,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType, ref } from "@nuxtjs/composition-api" import { defineComponent, PropType, ref } from "@nuxtjs/composition-api"
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data" import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash"
import { removeGraphqlRequest } from "~/newstore/collections" import { removeGraphqlRequest } from "~/newstore/collections"
import { setGQLSession } from "~/newstore/GQLSession" import { setGQLSession } from "~/newstore/GQLSession"
@@ -177,13 +178,16 @@ export default defineComponent({
this.pick() this.pick()
} else { } else {
setGQLSession({ setGQLSession({
request: makeGQLRequest({ request: cloneDeep(
name: this.$props.request.name, makeGQLRequest({
url: this.$props.request.url, name: this.$props.request.name,
query: this.$props.request.query, url: this.$props.request.url,
headers: this.$props.request.headers, query: this.$props.request.query,
variables: this.$props.request.variables, headers: this.$props.request.headers,
}), variables: this.$props.request.variables,
auth: this.$props.request.auth,
})
),
schema: "", schema: "",
response: "", response: "",
}) })

View File

@@ -0,0 +1,322 @@
<template>
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
>
<span class="flex items-center">
<label class="font-semibold text-secondaryLight">
{{ $t("authorization.type") }}
</label>
<tippy
ref="authTypeOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<span class="select-wrapper">
<ButtonSecondary
class="pr-8 ml-2 rounded-none"
:label="authName"
/>
</span>
</template>
<SmartItem
label="None"
:icon="
authName === 'None'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="authName === 'None'"
@click.native="
() => {
authType = 'none'
authTypeOptions.tippy().hide()
}
"
/>
<SmartItem
label="Basic Auth"
:icon="
authName === 'Basic Auth'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="authName === 'Basic Auth'"
@click.native="
() => {
authType = 'basic'
authTypeOptions.tippy().hide()
}
"
/>
<SmartItem
label="Bearer Token"
:icon="
authName === 'Bearer'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="authName === 'Bearer'"
@click.native="
() => {
authType = 'bearer'
authTypeOptions.tippy().hide()
}
"
/>
<SmartItem
label="OAuth 2.0"
:icon="
authName === 'OAuth 2.0'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="authName === 'OAuth 2.0'"
@click.native="
() => {
authType = 'oauth-2'
authTypeOptions.tippy().hide()
}
"
/>
<SmartItem
label="API key"
:icon="
authName === 'API key'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="authName === 'API key'"
@click.native="
() => {
authType = 'api-key'
authTypeOptions.tippy().hide()
}
"
/>
</tippy>
</span>
<div class="flex">
<!-- <SmartCheckbox
:on="!URLExcludes.auth"
@change="setExclude('auth', !$event)"
>
{{ $t("authorization.include_in_url") }}
</SmartCheckbox> -->
<SmartCheckbox
:on="authActive"
class="px-2"
@change="authActive = !authActive"
>
{{ $t("state.enabled") }}
</SmartCheckbox>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/authorization"
blank
:title="$t('app.wiki')"
svg="help-circle"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="$t('action.clear')"
svg="trash-2"
@click.native="clearContent"
/>
</div>
</div>
<div
v-if="authType === 'none'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${$colorMode.value}/login.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${$t('empty.authorization')}`"
/>
<span class="pb-4 text-center">
{{ $t("empty.authorization") }}
</span>
<ButtonSecondary
outline
:label="$t('app.documentation')"
to="https://docs.hoppscotch.io/features/authorization"
blank
svg="external-link"
reverse
class="mb-4"
/>
</div>
<div v-else class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight">
<div v-if="authType === 'basic'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="basicUsername"
:placeholder="$t('authorization.username')"
styles="bg-transparent flex flex-1 py-1 px-4"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="basicPassword"
:placeholder="$t('authorization.password')"
styles="bg-transparent flex flex-1 py-1 px-4"
/>
</div>
</div>
<div v-if="authType === 'bearer'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="bearerToken"
placeholder="Token"
styles="bg-transparent flex flex-1 py-1 px-4"
/>
</div>
</div>
<div v-if="authType === 'oauth-2'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="oauth2Token"
placeholder="Token"
styles="bg-transparent flex flex-1 py-1 px-4"
/>
</div>
<HttpOAuth2Authorization />
</div>
<div v-if="authType === 'api-key'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="apiKey"
placeholder="Key"
styles="bg-transparent flex flex-1 py-1 px-4"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="apiValue"
placeholder="Value"
styles="bg-transparent flex flex-1 py-1 px-4"
/>
</div>
<div class="flex items-center border-b border-dividerLight">
<label class="ml-4 text-secondaryLight">
{{ $t("authorization.pass_key_by") }}
</label>
<tippy
ref="addToOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<span class="select-wrapper">
<ButtonSecondary
:label="addTo || $t('state.none')"
class="pr-8 ml-2 rounded-none"
/>
</span>
</template>
<SmartItem
:icon="
addTo === 'Headers'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="addTo === 'Headers'"
:label="'Headers'"
@click.native="
() => {
addTo = 'Headers'
addToOptions.tippy().hide()
}
"
/>
<SmartItem
:icon="
addTo === 'Query params'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="addTo === 'Query params'"
:label="'Query params'"
@click.native="
() => {
addTo = 'Query params'
addToOptions.tippy().hide()
}
"
/>
</tippy>
</div>
</div>
</div>
<div
class="sticky h-full p-4 overflow-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
>
<div class="pb-2 text-secondaryLight">
{{ $t("helpers.authorization") }}
</div>
<SmartAnchor
class="link"
:label="`${$t('authorization.learn')} \xA0 →`"
to="https://docs.hoppscotch.io/features/authorization"
blank
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, Ref } from "@nuxtjs/composition-api"
import {
HoppGQLAuthAPIKey,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthOAuth2,
} from "@hoppscotch/data"
import { pluckRef, useStream } from "~/helpers/utils/composables"
import { gqlAuth$, setGQLAuth } from "~/newstore/GQLSession"
const auth = useStream(
gqlAuth$,
{ authType: "none", authActive: true },
setGQLAuth
)
const authType = pluckRef(auth, "authType")
const authName = computed(() => {
if (authType.value === "basic") return "Basic Auth"
else if (authType.value === "bearer") return "Bearer"
else if (authType.value === "oauth-2") return "OAuth 2.0"
else if (authType.value === "api-key") return "API key"
else return "None"
})
const authActive = pluckRef(auth, "authActive")
const basicUsername = pluckRef(auth as Ref<HoppGQLAuthBasic>, "username")
const basicPassword = pluckRef(auth as Ref<HoppGQLAuthBasic>, "password")
const bearerToken = pluckRef(auth as Ref<HoppGQLAuthBearer>, "token")
const oauth2Token = pluckRef(auth as Ref<HoppGQLAuthOAuth2>, "token")
const apiKey = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "key")
const apiValue = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "value")
const addTo = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "addTo")
if (typeof addTo.value === "undefined") {
addTo.value = "Headers"
apiKey.value = ""
apiValue.value = ""
}
const clearContent = () => {
auth.value = {
authType: "none",
authActive: true,
}
}
const authTypeOptions = ref<any | null>(null)
const addToOptions = ref<any | null>(null)
</script>

View File

@@ -260,6 +260,9 @@
</div> </div>
</div> </div>
</SmartTab> </SmartTab>
<SmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
<GraphqlAuthorization />
</SmartTab>
</SmartTabs> </SmartTabs>
<CollectionsSaveRequest <CollectionsSaveRequest
mode="graphql" mode="graphql"
@@ -296,11 +299,13 @@ import {
useToast, useToast,
} from "~/helpers/utils/composables" } from "~/helpers/utils/composables"
import { import {
gqlAuth$,
gqlHeaders$, gqlHeaders$,
gqlQuery$, gqlQuery$,
gqlResponse$, gqlResponse$,
gqlURL$, gqlURL$,
gqlVariables$, gqlVariables$,
setGQLAuth,
setGQLHeaders, setGQLHeaders,
setGQLQuery, setGQLQuery,
setGQLResponse, setGQLResponse,
@@ -353,6 +358,12 @@ useCodemirror(bulkEditor, bulkHeaders, {
// The functional headers list (the headers actually in the system) // The functional headers list (the headers actually in the system)
const headers = useStream(gqlHeaders$, [], setGQLHeaders) as Ref<GQLHeader[]> const headers = useStream(gqlHeaders$, [], setGQLHeaders) as Ref<GQLHeader[]>
const auth = useStream(
gqlAuth$,
{ authType: "none", authActive: true },
setGQLAuth
)
// The UI representation of the headers list (has the empty end header) // The UI representation of the headers list (has the empty end header)
const workingHeaders = ref<Array<GQLHeader & { id: number }>>([ const workingHeaders = ref<Array<GQLHeader & { id: number }>>([
{ {
@@ -602,12 +613,14 @@ const runQuery = async () => {
const runHeaders = clone(headers.value) const runHeaders = clone(headers.value)
const runQuery = clone(gqlQueryString.value) const runQuery = clone(gqlQueryString.value)
const runVariables = clone(variableString.value) const runVariables = clone(variableString.value)
const runAuth = clone(auth.value)
const responseText = await props.conn.runQuery( const responseText = await props.conn.runQuery(
runURL, runURL,
runHeaders, runHeaders,
runQuery, runQuery,
runVariables runVariables,
runAuth
) )
const duration = Date.now() - startTime const duration = Date.now() - startTime
@@ -623,6 +636,7 @@ const runQuery = async () => {
query: runQuery, query: runQuery,
headers: runHeaders, headers: runHeaders,
variables: runVariables, variables: runVariables,
auth: runAuth,
}), }),
response: response.value, response: response.value,
star: false, star: false,

View File

@@ -210,6 +210,7 @@ import {
useToast, useToast,
} from "~/helpers/utils/composables" } from "~/helpers/utils/composables"
import { import {
setGQLAuth,
setGQLHeaders, setGQLHeaders,
setGQLQuery, setGQLQuery,
setGQLResponse, setGQLResponse,
@@ -451,6 +452,10 @@ const handleUseHistory = (entry: GQLHistoryEntry) => {
setGQLQuery(gqlQueryString) setGQLQuery(gqlQueryString)
setGQLVariables(variableString) setGQLVariables(variableString)
setGQLResponse(responseText) setGQLResponse(responseText)
setGQLAuth({
authType: "none",
authActive: true,
})
props.conn.reset() props.conn.reset()
} }
</script> </script>

View File

@@ -58,6 +58,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "@nuxtjs/composition-api" import { computed, ref } from "@nuxtjs/composition-api"
import { makeGQLRequest } from "@hoppscotch/data" import { makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash"
import { setGQLSession } from "~/newstore/GQLSession" import { setGQLSession } from "~/newstore/GQLSession"
import { GQLHistoryEntry } from "~/newstore/history" import { GQLHistoryEntry } from "~/newstore/history"
@@ -79,13 +80,16 @@ const query = computed(() =>
const useEntry = () => { const useEntry = () => {
setGQLSession({ setGQLSession({
request: makeGQLRequest({ request: cloneDeep(
name: props.entry.request.name, makeGQLRequest({
url: props.entry.request.url, name: props.entry.request.name,
headers: props.entry.request.headers, url: props.entry.request.url,
query: props.entry.request.query, headers: props.entry.request.headers,
variables: props.entry.request.variables, query: props.entry.request.query,
}), variables: props.entry.request.variables,
auth: props.entry.request.auth,
})
),
schema: "", schema: "",
response: props.entry.response, response: props.entry.response,
}) })

View File

@@ -10,7 +10,7 @@ import {
GraphQLInterfaceType, GraphQLInterfaceType,
} from "graphql" } from "graphql"
import { distinctUntilChanged, map } from "rxjs/operators" import { distinctUntilChanged, map } from "rxjs/operators"
import { GQLHeader } from "@hoppscotch/data" import { GQLHeader, HoppGQLAuth } from "@hoppscotch/data"
import { sendNetworkRequest } from "./network" import { sendNetworkRequest } from "./network"
const GQL_SCHEMA_POLL_INTERVAL = 7000 const GQL_SCHEMA_POLL_INTERVAL = 7000
@@ -182,15 +182,36 @@ export class GQLConnection {
url: string, url: string,
headers: GQLHeader[], headers: GQLHeader[],
query: string, query: string,
variables: string variables: string,
auth: HoppGQLAuth
) { ) {
const finalHeaders: Record<string, string> = {} 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 headers
.filter((item) => item.active && item.key !== "") .filter((item) => item.active && item.key !== "")
.forEach(({ key, value }) => (finalHeaders[key] = value)) .forEach(({ key, value }) => (finalHeaders[key] = value))
const parsedVariables = JSON.parse(variables || "{}")
const reqOptions = { const reqOptions = {
method: "post", method: "post",
url, url,
@@ -202,6 +223,9 @@ export class GQLConnection {
query, query,
variables: parsedVariables, variables: parsedVariables,
}), }),
params: {
...params,
},
} }
const res = await sendNetworkRequest(reqOptions) const res = await sendNetworkRequest(reqOptions)

View File

@@ -1,5 +1,10 @@
import { distinctUntilChanged, pluck } from "rxjs/operators" import { distinctUntilChanged, pluck } from "rxjs/operators"
import { GQLHeader, HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data" import {
GQLHeader,
HoppGQLRequest,
makeGQLRequest,
HoppGQLAuth,
} from "@hoppscotch/data"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore" import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { useStream } from "~/helpers/utils/composables" import { useStream } from "~/helpers/utils/composables"
@@ -26,6 +31,10 @@ export const defaultGQLSession: GQLSession = {
} }
} }
`, `,
auth: {
authType: "none",
authActive: true,
},
}), }),
schema: "", schema: "",
response: "", response: "",
@@ -112,6 +121,14 @@ const dispatchers = defineDispatchers({
response: newResponse, response: newResponse,
} }
}, },
setAuth(curr: GQLSession, { newAuth }: { newAuth: HoppGQLAuth }) {
return {
request: {
...curr.request,
auth: newAuth,
},
}
},
}) })
export const gqlSessionStore = new DispatchingStore( export const gqlSessionStore = new DispatchingStore(
@@ -223,6 +240,15 @@ export function useGQLRequestName() {
}) })
} }
export function setGQLAuth(newAuth: HoppGQLAuth) {
gqlSessionStore.dispatch({
dispatcher: "setAuth",
payload: {
newAuth,
},
})
}
export const gqlName$ = gqlSessionStore.subject$.pipe( export const gqlName$ = gqlSessionStore.subject$.pipe(
pluck("request", "name"), pluck("request", "name"),
distinctUntilChanged() distinctUntilChanged()
@@ -248,3 +274,8 @@ export const gqlResponse$ = gqlSessionStore.subject$.pipe(
pluck("response"), pluck("response"),
distinctUntilChanged() distinctUntilChanged()
) )
export const gqlAuth$ = gqlSessionStore.subject$.pipe(
pluck("request", "auth"),
distinctUntilChanged()
)

View File

@@ -5,6 +5,7 @@ import {
translateToNewRequest, translateToNewRequest,
HoppGQLRequest, HoppGQLRequest,
translateToGQLRequest, translateToGQLRequest,
GQL_REQ_SCHEMA_VERSION,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore" import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { completedRESTResponse$ } from "./RESTSession" import { completedRESTResponse$ } from "./RESTSession"
@@ -83,10 +84,12 @@ export function translateToNewRESTHistory(x: any): RESTHistoryEntry {
} }
export function translateToNewGQLHistory(x: any): GQLHistoryEntry { export function translateToNewGQLHistory(x: any): GQLHistoryEntry {
if (x.v === 1) return x if (x.v === 1 && x.request.v === GQL_REQ_SCHEMA_VERSION) return x
// Legacy // Legacy
const request = translateToGQLRequest(x) const request = x.request
? translateToGQLRequest(x.request)
: translateToGQLRequest(x)
const star = x.star ?? false const star = x.star ?? false
const response = x.response ?? "" const response = x.response ?? ""
const updatedOn = x.updatedOn ?? "" const updatedOn = x.updatedOn ?? ""

View File

@@ -1,6 +1,6 @@
{ {
"name": "@hoppscotch/data", "name": "@hoppscotch/data",
"version": "0.4.0", "version": "0.4.1",
"description": "Data Types, Validations and Migrations for Hoppscotch Public Data Structures", "description": "Data Types, Validations and Migrations for Hoppscotch Public Data Structures",
"main": "dist/index.js", "main": "dist/index.js",
"module": "true", "module": "true",

View File

@@ -1,4 +1,4 @@
import { HoppGQLRequest, translateToGQLRequest } from "../graphql"; import { GQL_REQ_SCHEMA_VERSION, HoppGQLRequest, translateToGQLRequest } from "../graphql";
import { HoppRESTRequest, translateToNewRequest } from "../rest"; import { HoppRESTRequest, translateToNewRequest } from "../rest";
const CURRENT_COLL_SCHEMA_VER = 1 const CURRENT_COLL_SCHEMA_VER = 1
@@ -65,7 +65,7 @@ export function translateToNewRESTCollection(
export function translateToNewGQLCollection( export function translateToNewGQLCollection(
x: any x: any
): HoppCollection<HoppGQLRequest> { ): HoppCollection<HoppGQLRequest> {
if (x.v && x.v === 1) return x if (x.v && x.v === GQL_REQ_SCHEMA_VERSION) return x
// Legacy // Legacy
const name = x.name ?? "Untitled" const name = x.name ?? "Untitled"

View File

@@ -0,0 +1,43 @@
export type HoppGQLAuthNone = {
authType: "none"
}
export type HoppGQLAuthBasic = {
authType: "basic"
username: string
password: string
}
export type HoppGQLAuthBearer = {
authType: "bearer"
token: string
}
export type HoppGQLAuthOAuth2 = {
authType: "oauth-2"
token: string
oidcDiscoveryURL: string
authURL: string
accessTokenURL: string
clientID: string
scope: string
}
export type HoppGQLAuthAPIKey = {
authType: "api-key"
key: string
value: string
addTo: string
}
export type HoppGQLAuth = { authActive: boolean } & (
| HoppGQLAuthNone
| HoppGQLAuthBasic
| HoppGQLAuthBearer
| HoppGQLAuthOAuth2
| HoppGQLAuthAPIKey
)

View File

@@ -1,3 +1,9 @@
import { HoppGQLAuth } from "./HoppGQLAuth"
export * from "./HoppGQLAuth"
export const GQL_REQ_SCHEMA_VERSION = 2
export type GQLHeader = { export type GQLHeader = {
key: string key: string
value: string value: string
@@ -11,10 +17,11 @@ export type HoppGQLRequest = {
headers: GQLHeader[] headers: GQLHeader[]
query: string query: string
variables: string variables: string
auth: HoppGQLAuth
} }
export function translateToGQLRequest(x: any): HoppGQLRequest { export function translateToGQLRequest(x: any): HoppGQLRequest {
if (x.v && x.v === 1) return x if (x.v && x.v === GQL_REQ_SCHEMA_VERSION) return x
// Old request // Old request
const name = x.name ?? "Untitled" const name = x.name ?? "Untitled"
@@ -22,20 +29,25 @@ export function translateToGQLRequest(x: any): HoppGQLRequest {
const headers = x.headers ?? [] const headers = x.headers ?? []
const query = x.query ?? "" const query = x.query ?? ""
const variables = x.variables ?? [] const variables = x.variables ?? []
const auth = x.auth ?? {
authType: "none",
authActive: true,
}
return { return {
v: 1, v: GQL_REQ_SCHEMA_VERSION,
name, name,
url, url,
headers, headers,
query, query,
variables, variables,
auth
} }
} }
export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">) { export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">): HoppGQLRequest {
return { return {
v: 1, v: GQL_REQ_SCHEMA_VERSION,
...x, ...x,
} }
} }