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">
import { defineComponent, PropType, ref } from "@nuxtjs/composition-api"
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash"
import { removeGraphqlRequest } from "~/newstore/collections"
import { setGQLSession } from "~/newstore/GQLSession"
@@ -177,13 +178,16 @@ export default defineComponent({
this.pick()
} else {
setGQLSession({
request: makeGQLRequest({
name: this.$props.request.name,
url: this.$props.request.url,
query: this.$props.request.query,
headers: this.$props.request.headers,
variables: this.$props.request.variables,
}),
request: cloneDeep(
makeGQLRequest({
name: this.$props.request.name,
url: this.$props.request.url,
query: this.$props.request.query,
headers: this.$props.request.headers,
variables: this.$props.request.variables,
auth: this.$props.request.auth,
})
),
schema: "",
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>
</SmartTab>
<SmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
<GraphqlAuthorization />
</SmartTab>
</SmartTabs>
<CollectionsSaveRequest
mode="graphql"
@@ -296,11 +299,13 @@ import {
useToast,
} from "~/helpers/utils/composables"
import {
gqlAuth$,
gqlHeaders$,
gqlQuery$,
gqlResponse$,
gqlURL$,
gqlVariables$,
setGQLAuth,
setGQLHeaders,
setGQLQuery,
setGQLResponse,
@@ -353,6 +358,12 @@ useCodemirror(bulkEditor, bulkHeaders, {
// The functional headers list (the headers actually in the system)
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)
const workingHeaders = ref<Array<GQLHeader & { id: number }>>([
{
@@ -602,12 +613,14 @@ const runQuery = async () => {
const runHeaders = clone(headers.value)
const runQuery = clone(gqlQueryString.value)
const runVariables = clone(variableString.value)
const runAuth = clone(auth.value)
const responseText = await props.conn.runQuery(
runURL,
runHeaders,
runQuery,
runVariables
runVariables,
runAuth
)
const duration = Date.now() - startTime
@@ -623,6 +636,7 @@ const runQuery = async () => {
query: runQuery,
headers: runHeaders,
variables: runVariables,
auth: runAuth,
}),
response: response.value,
star: false,

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
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 { useStream } from "~/helpers/utils/composables"
@@ -26,6 +31,10 @@ export const defaultGQLSession: GQLSession = {
}
}
`,
auth: {
authType: "none",
authActive: true,
},
}),
schema: "",
response: "",
@@ -112,6 +121,14 @@ const dispatchers = defineDispatchers({
response: newResponse,
}
},
setAuth(curr: GQLSession, { newAuth }: { newAuth: HoppGQLAuth }) {
return {
request: {
...curr.request,
auth: newAuth,
},
}
},
})
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(
pluck("request", "name"),
distinctUntilChanged()
@@ -248,3 +274,8 @@ export const gqlResponse$ = gqlSessionStore.subject$.pipe(
pluck("response"),
distinctUntilChanged()
)
export const gqlAuth$ = gqlSessionStore.subject$.pipe(
pluck("request", "auth"),
distinctUntilChanged()
)

View File

@@ -5,6 +5,7 @@ import {
translateToNewRequest,
HoppGQLRequest,
translateToGQLRequest,
GQL_REQ_SCHEMA_VERSION,
} from "@hoppscotch/data"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { completedRESTResponse$ } from "./RESTSession"
@@ -83,10 +84,12 @@ export function translateToNewRESTHistory(x: any): RESTHistoryEntry {
}
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
const request = translateToGQLRequest(x)
const request = x.request
? translateToGQLRequest(x.request)
: translateToGQLRequest(x)
const star = x.star ?? false
const response = x.response ?? ""
const updatedOn = x.updatedOn ?? ""

View File

@@ -1,6 +1,6 @@
{
"name": "@hoppscotch/data",
"version": "0.4.0",
"version": "0.4.1",
"description": "Data Types, Validations and Migrations for Hoppscotch Public Data Structures",
"main": "dist/index.js",
"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";
const CURRENT_COLL_SCHEMA_VER = 1
@@ -65,7 +65,7 @@ export function translateToNewRESTCollection(
export function translateToNewGQLCollection(
x: any
): HoppCollection<HoppGQLRequest> {
if (x.v && x.v === 1) return x
if (x.v && x.v === GQL_REQ_SCHEMA_VERSION) return x
// Legacy
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 = {
key: string
value: string
@@ -11,10 +17,11 @@ export type HoppGQLRequest = {
headers: GQLHeader[]
query: string
variables: string
auth: HoppGQLAuth
}
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
const name = x.name ?? "Untitled"
@@ -22,20 +29,25 @@ export function translateToGQLRequest(x: any): HoppGQLRequest {
const headers = x.headers ?? []
const query = x.query ?? ""
const variables = x.variables ?? []
const auth = x.auth ?? {
authType: "none",
authActive: true,
}
return {
v: 1,
v: GQL_REQ_SCHEMA_VERSION,
name,
url,
headers,
query,
variables,
auth
}
}
export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">) {
export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">): HoppGQLRequest {
return {
v: 1,
v: GQL_REQ_SCHEMA_VERSION,
...x,
}
}