Merge branch 'orphan-pr/2243' into 2087-openapi
This commit is contained in:
@@ -25,7 +25,7 @@ import { getRESTRequest, setRESTTestResults } from "~/newstore/RESTSession"
|
||||
import {
|
||||
environmentsStore,
|
||||
getCurrentEnvironment,
|
||||
getEnviroment,
|
||||
getEnvironment,
|
||||
getGlobalVariables,
|
||||
setGlobalEnvVariables,
|
||||
updateEnvironment,
|
||||
@@ -97,7 +97,7 @@ export const runRESTRequest$ = (): TaskEither<
|
||||
setGlobalEnvVariables(runResult.right.envs.global)
|
||||
|
||||
if (environmentsStore.value.currentEnvironmentIndex !== -1) {
|
||||
const env = getEnviroment(
|
||||
const env = getEnvironment(
|
||||
environmentsStore.value.currentEnvironmentIndex
|
||||
)
|
||||
updateEnvironment(
|
||||
|
||||
@@ -45,28 +45,23 @@ import {
|
||||
} from "~/helpers/fb/auth"
|
||||
|
||||
const BACKEND_GQL_URL =
|
||||
process.env.context === "production"
|
||||
? "https://api.hoppscotch.io/graphql"
|
||||
: "https://api.hoppscotch.io/graphql"
|
||||
process.env.BACKEND_GQL_URL ?? "https://api.hoppscotch.io/graphql"
|
||||
const BACKEND_WS_URL =
|
||||
process.env.BACKEND_WS_URL ?? "wss://api.hoppscotch.io/graphql"
|
||||
|
||||
// const storage = makeDefaultStorage({
|
||||
// idbName: "hoppcache-v1",
|
||||
// maxAge: 7,
|
||||
// })
|
||||
|
||||
const subscriptionClient = new SubscriptionClient(
|
||||
process.env.context === "production"
|
||||
? "wss://api.hoppscotch.io/graphql"
|
||||
: "wss://api.hoppscotch.io/graphql",
|
||||
{
|
||||
reconnect: true,
|
||||
connectionParams: () => {
|
||||
return {
|
||||
authorization: `Bearer ${authIdToken$.value}`,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
const subscriptionClient = new SubscriptionClient(BACKEND_WS_URL, {
|
||||
reconnect: true,
|
||||
connectionParams: () => {
|
||||
return {
|
||||
authorization: `Bearer ${authIdToken$.value}`,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
authIdToken$.subscribe(() => {
|
||||
subscriptionClient.client?.close()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation DeleteShortcode($code: ID!) {
|
||||
revokeShortcode(code: $code)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
query GetUserShortcodes($cursor: ID) {
|
||||
myShortcodes(cursor: $cursor) {
|
||||
id
|
||||
request
|
||||
createdOn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
subscription ShortcodeCreated {
|
||||
myShortcodesCreated {
|
||||
id
|
||||
request
|
||||
createdOn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
subscription ShortcodeDeleted {
|
||||
myShortcodesRevoked {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
GetCollectionTitleDocument,
|
||||
} from "./graphql"
|
||||
|
||||
const BACKEND_PAGE_SIZE = 10
|
||||
export const BACKEND_PAGE_SIZE = 10
|
||||
|
||||
const getCollectionChildrenIDs = async (collID: string) => {
|
||||
const collsList: string[] = []
|
||||
|
||||
@@ -4,8 +4,13 @@ import {
|
||||
CreateShortcodeDocument,
|
||||
CreateShortcodeMutation,
|
||||
CreateShortcodeMutationVariables,
|
||||
DeleteShortcodeDocument,
|
||||
DeleteShortcodeMutation,
|
||||
DeleteShortcodeMutationVariables,
|
||||
} from "../graphql"
|
||||
|
||||
type DeleteShortcodeErrors = "shortcode/not_found"
|
||||
|
||||
export const createShortcode = (request: HoppRESTRequest) =>
|
||||
runMutation<CreateShortcodeMutation, CreateShortcodeMutationVariables, "">(
|
||||
CreateShortcodeDocument,
|
||||
@@ -13,3 +18,12 @@ export const createShortcode = (request: HoppRESTRequest) =>
|
||||
request: JSON.stringify(request),
|
||||
}
|
||||
)
|
||||
|
||||
export const deleteShortcode = (code: string) =>
|
||||
runMutation<
|
||||
DeleteShortcodeMutation,
|
||||
DeleteShortcodeMutationVariables,
|
||||
DeleteShortcodeErrors
|
||||
>(DeleteShortcodeDocument, {
|
||||
code,
|
||||
})
|
||||
|
||||
@@ -768,11 +768,52 @@ const samples = [
|
||||
testScript: "",
|
||||
}),
|
||||
},
|
||||
{
|
||||
command: `curl \`
|
||||
google.com -H "content-type: application/json"`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://google.com/",
|
||||
auth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
body: {
|
||||
contentType: null,
|
||||
body: null,
|
||||
},
|
||||
params: [],
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
}),
|
||||
},
|
||||
{
|
||||
command: `curl 192.168.0.24:8080/ping`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://192.168.0.24:8080/ping",
|
||||
auth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
body: {
|
||||
contentType: null,
|
||||
body: null,
|
||||
},
|
||||
params: [],
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
describe("parseCurlToHoppRESTReq", () => {
|
||||
describe("Parse curl command to Hopp REST Request", () => {
|
||||
for (const [i, { command, response }] of samples.entries()) {
|
||||
test(`matches expectation for sample #${i + 1}`, () => {
|
||||
test(`for sample #${i + 1}:\n\n${command}`, () => {
|
||||
expect(parseCurlToHoppRESTReq(command)).toEqual(response)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getHeaders, recordToHoppHeaders } from "./sub_helpers/headers"
|
||||
// import { getCookies } from "./sub_helpers/cookies"
|
||||
import { getQueries } from "./sub_helpers/queries"
|
||||
import { getMethod } from "./sub_helpers/method"
|
||||
import { concatParams, parseURL } from "./sub_helpers/url"
|
||||
import { concatParams, getURLObject } from "./sub_helpers/url"
|
||||
import { preProcessCurlCommand } from "./sub_helpers/preproc"
|
||||
import { getBody, getFArgumentMultipartData } from "./sub_helpers/body"
|
||||
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
|
||||
@@ -42,7 +42,7 @@ export const parseCurlCommand = (curlCommand: string) => {
|
||||
|
||||
const method = getMethod(parsedArguments)
|
||||
// const cookies = getCookies(parsedArguments)
|
||||
const urlObject = parseURL(parsedArguments)
|
||||
const urlObject = getURLObject(parsedArguments)
|
||||
const auth = getAuthObject(parsedArguments, headers, urlObject)
|
||||
|
||||
let rawData: string | string[] = pipe(
|
||||
|
||||
@@ -161,8 +161,7 @@ const getXMLBody = (rawData: string) =>
|
||||
const getFormattedJSON = flow(
|
||||
safeParseJSON,
|
||||
O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)),
|
||||
O.getOrElse(() => "{}"),
|
||||
O.of
|
||||
O.getOrElse(() => "{ }")
|
||||
)
|
||||
|
||||
const getXWWWFormUrlEncodedBody = flow(
|
||||
@@ -189,7 +188,7 @@ export function parseBody(
|
||||
case "application/ld+json":
|
||||
case "application/vnd.api+json":
|
||||
case "application/json":
|
||||
return getFormattedJSON(rawData)
|
||||
return O.some(getFormattedJSON(rawData))
|
||||
|
||||
case "application/x-www-form-urlencoded":
|
||||
return getXWWWFormUrlEncodedBody(rawData)
|
||||
|
||||
@@ -19,10 +19,11 @@ const replaceables: { [key: string]: string } = {
|
||||
const paperCuts = flow(
|
||||
// remove '\' and newlines
|
||||
S.replace(/ ?\\ ?$/gm, " "),
|
||||
S.replace(/\n/g, ""),
|
||||
S.replace(/\n/g, " "),
|
||||
// remove all $ symbols from start of argument values
|
||||
S.replace(/\$'/g, "'"),
|
||||
S.replace(/\$"/g, '"')
|
||||
S.replace(/\$"/g, '"'),
|
||||
S.trim
|
||||
)
|
||||
|
||||
// replace --zargs option with -Z
|
||||
|
||||
@@ -1,48 +1,80 @@
|
||||
import parser from "yargs-parser"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
|
||||
import { stringArrayJoin } from "~/helpers/functional/array"
|
||||
|
||||
const defaultRESTReq = getDefaultRESTRequest()
|
||||
|
||||
const getProtocolForBaseURL = (baseURL: string) =>
|
||||
const getProtocolFromURL = (url: string) =>
|
||||
pipe(
|
||||
// get the base URL
|
||||
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(baseURL),
|
||||
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(url),
|
||||
O.fromNullable,
|
||||
O.filter((burl) => burl.length > 1),
|
||||
O.map((burl) => burl[2]),
|
||||
// set protocol to http for local URLs
|
||||
O.map((burl) =>
|
||||
burl === "localhost" || burl === "127.0.0.1"
|
||||
? "http://" + baseURL
|
||||
: "https://" + baseURL
|
||||
burl === "localhost" ||
|
||||
burl === "2130706433" ||
|
||||
/127(\.0){0,2}\.1/.test(burl) ||
|
||||
/0177(\.0){0,2}\.1/.test(burl) ||
|
||||
/0x7f(\.0){0,2}\.1/.test(burl) ||
|
||||
/192\.168(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){2}/.test(burl) ||
|
||||
/10(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/.test(burl)
|
||||
? "http://" + url
|
||||
: "https://" + url
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks if the URL is valid using the URL constructor
|
||||
* @param urlString URL string (with protocol)
|
||||
* @returns boolean whether the URL is valid using the inbuilt URL class
|
||||
*/
|
||||
const isURLValid = (urlString: string) =>
|
||||
pipe(
|
||||
O.tryCatch(() => new URL(urlString)),
|
||||
O.isSome
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks and returns URL object for the valid URL
|
||||
* @param urlText Raw URL string provided by argument parser
|
||||
* @returns Option of URL object
|
||||
*/
|
||||
const parseURL = (urlText: string | number) =>
|
||||
pipe(
|
||||
urlText,
|
||||
O.fromNullable,
|
||||
// preprocess url string
|
||||
O.map((u) => u.toString().replaceAll(/[^a-zA-Z0-9_\-./?&=:@%+#,;\s]/g, "")),
|
||||
O.filter((u) => u.length > 0),
|
||||
O.chain((u) =>
|
||||
pipe(
|
||||
u,
|
||||
// check if protocol is available
|
||||
O.fromPredicate(
|
||||
(url: string) => /^[^:\s]+(?=:\/\/)/.exec(url) !== null
|
||||
),
|
||||
O.alt(() => getProtocolFromURL(u))
|
||||
)
|
||||
),
|
||||
O.filter(isURLValid),
|
||||
O.map((u) => new URL(u))
|
||||
)
|
||||
|
||||
/**
|
||||
* Processes URL string and returns the URL object
|
||||
* @param parsedArguments Parsed Arguments object
|
||||
* @returns URL object
|
||||
*/
|
||||
export function parseURL(parsedArguments: parser.Arguments) {
|
||||
export function getURLObject(parsedArguments: parser.Arguments) {
|
||||
return pipe(
|
||||
// contains raw url string
|
||||
parsedArguments._[1],
|
||||
O.fromNullable,
|
||||
// preprocess url string
|
||||
O.map((u) => u.toString().replace(/["']/g, "").trim()),
|
||||
O.chain((u) =>
|
||||
pipe(
|
||||
// check if protocol is available
|
||||
/^[^:\s]+(?=:\/\/)/.exec(u),
|
||||
O.fromNullable,
|
||||
O.map((_) => u),
|
||||
O.alt(() => getProtocolForBaseURL(u))
|
||||
)
|
||||
),
|
||||
O.map((u) => new URL(u)),
|
||||
// contains raw url strings
|
||||
parsedArguments._.slice(1),
|
||||
A.findFirstMap(parseURL),
|
||||
// no url found
|
||||
O.getOrElse(() => new URL(defaultRESTReq.endpoint))
|
||||
)
|
||||
|
||||
@@ -28,8 +28,6 @@ import { javascriptLanguage } from "@codemirror/lang-javascript"
|
||||
import { xmlLanguage } from "@codemirror/lang-xml"
|
||||
import { jsonLanguage } from "@codemirror/lang-json"
|
||||
import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { StreamLanguage } from "@codemirror/stream-parser"
|
||||
import { html } from "@codemirror/legacy-modes/mode/xml"
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell"
|
||||
@@ -40,7 +38,6 @@ import { Completer } from "./completion"
|
||||
import { LinterDefinition } from "./linting/linter"
|
||||
import { basicSetup, baseTheme, baseHighlightStyle } from "./themes/baseTheme"
|
||||
import { HoppEnvironmentPlugin } from "./extensions/HoppEnvironment"
|
||||
import { IndentedLineWrapPlugin } from "./extensions/IndentedLineWrap"
|
||||
// TODO: Migrate from legacy mode
|
||||
|
||||
type ExtendedEditorConfig = {
|
||||
@@ -96,8 +93,10 @@ const hoppCompleterExt = (completer: Completer): Extension => {
|
||||
})
|
||||
}
|
||||
|
||||
const hoppLinterExt = (hoppLinter: LinterDefinition): Extension => {
|
||||
const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => {
|
||||
return linter(async (view) => {
|
||||
if (!hoppLinter) return []
|
||||
|
||||
// Requires full document scan, hence expensive on big files, force disable on big files ?
|
||||
const linterResult = await hoppLinter(
|
||||
view.state.doc.toJSON().join(view.state.lineBreak)
|
||||
@@ -119,16 +118,16 @@ const hoppLinterExt = (hoppLinter: LinterDefinition): Extension => {
|
||||
}
|
||||
|
||||
const hoppLang = (
|
||||
language: Language,
|
||||
language: Language | undefined,
|
||||
linter?: LinterDefinition | undefined,
|
||||
completer?: Completer | undefined
|
||||
) => {
|
||||
): Extension | LanguageSupport => {
|
||||
const exts: Extension[] = []
|
||||
|
||||
if (linter) exts.push(hoppLinterExt(linter))
|
||||
exts.push(hoppLinterExt(linter))
|
||||
if (completer) exts.push(hoppCompleterExt(completer))
|
||||
|
||||
return new LanguageSupport(language, exts)
|
||||
return language ? new LanguageSupport(language, exts) : exts
|
||||
}
|
||||
|
||||
const getLanguage = (langMime: string): Language | null => {
|
||||
@@ -156,12 +155,7 @@ const getEditorLanguage = (
|
||||
langMime: string,
|
||||
linter: LinterDefinition | undefined,
|
||||
completer: Completer | undefined
|
||||
): Extension =>
|
||||
pipe(
|
||||
O.fromNullable(getLanguage(langMime)),
|
||||
O.map((lang) => hoppLang(lang, linter, completer)),
|
||||
O.getOrElseW(() => [])
|
||||
)
|
||||
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
|
||||
|
||||
export function useCodemirror(
|
||||
el: Ref<any | null>,
|
||||
@@ -243,7 +237,7 @@ export function useCodemirror(
|
||||
),
|
||||
lineWrapping.of(
|
||||
options.extendedEditorConfig.lineWrapping
|
||||
? [IndentedLineWrapPlugin]
|
||||
? [EditorView.lineWrapping]
|
||||
: []
|
||||
),
|
||||
keymap.of([
|
||||
@@ -330,7 +324,7 @@ export function useCodemirror(
|
||||
(newMode) => {
|
||||
view.value?.dispatch({
|
||||
effects: lineWrapping.reconfigure(
|
||||
newMode ? [EditorView.lineWrapping, IndentedLineWrapPlugin] : []
|
||||
newMode ? [EditorView.lineWrapping] : []
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { EditorView } from "@codemirror/view"
|
||||
|
||||
const WrappedLineIndenter = EditorView.updateListener.of((update) => {
|
||||
const view = update.view
|
||||
const charWidth = view.defaultCharacterWidth
|
||||
const lineHeight = view.defaultLineHeight
|
||||
const basePadding = 10
|
||||
|
||||
view.viewportLines((line) => {
|
||||
const domAtPos = view.domAtPos(line.from)
|
||||
|
||||
const lineCount = (line.bottom - line.top) / lineHeight
|
||||
|
||||
if (lineCount <= 1) return
|
||||
|
||||
const belowPadding = basePadding * charWidth
|
||||
|
||||
const node = domAtPos.node as HTMLElement
|
||||
node.style.textIndent = `-${belowPadding - charWidth + 1}px`
|
||||
node.style.paddingLeft = `${belowPadding}px`
|
||||
})
|
||||
})
|
||||
|
||||
export const IndentedLineWrapPlugin = [
|
||||
EditorView.lineWrapping,
|
||||
WrappedLineIndenter,
|
||||
]
|
||||
@@ -17,6 +17,6 @@ export const trace = <T>(x: T) => {
|
||||
export const namedTrace =
|
||||
(name: string) =>
|
||||
<T>(x: T) => {
|
||||
console.log(`${name}: `, x)
|
||||
console.log(`${name}:`, x)
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as O from "fp-ts/Option"
|
||||
import { flow } from "fp-ts/function"
|
||||
|
||||
/**
|
||||
* Checks and Parses JSON string
|
||||
@@ -15,3 +16,10 @@ export const safeParseJSON = (str: string): O.Option<object> =>
|
||||
*/
|
||||
export const prettyPrintJSON = (obj: unknown): O.Option<string> =>
|
||||
O.tryCatch(() => JSON.stringify(obj, null, "\t"))
|
||||
|
||||
/**
|
||||
* Checks if given string is a JSON string
|
||||
* @param str Raw string to be checked
|
||||
* @returns If string is a JSON string
|
||||
*/
|
||||
export const isJSON = flow(safeParseJSON, O.isSome)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Ref, ref } from "@nuxtjs/composition-api"
|
||||
import { Ref } from "@nuxtjs/composition-api"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { useI18n, useToast } from "~/helpers/utils/composables"
|
||||
|
||||
@@ -8,13 +9,13 @@ export default function useCopyResponse(responseBodyText: Ref<any>): {
|
||||
} {
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
const copyIcon = ref("copy")
|
||||
|
||||
const copyIcon = refAutoReset<"copy" | "check">("copy", 1000)
|
||||
|
||||
const copyResponse = () => {
|
||||
copyToClipboard(responseBodyText.value)
|
||||
copyIcon.value = "check"
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
setTimeout(() => (copyIcon.value = "copy"), 1000)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as S from "fp-ts/string"
|
||||
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { Ref, ref } from "@nuxtjs/composition-api"
|
||||
import { Ref } from "@nuxtjs/composition-api"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useI18n, useToast } from "~/helpers/utils/composables"
|
||||
|
||||
export type downloadResponseReturnType = (() => void) | Ref<any>
|
||||
@@ -13,7 +14,8 @@ export default function useDownloadResponse(
|
||||
downloadIcon: Ref<string>
|
||||
downloadResponse: () => void
|
||||
} {
|
||||
const downloadIcon = ref("download")
|
||||
const downloadIcon = refAutoReset<"download" | "check">("download", 1000)
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
@@ -42,7 +44,6 @@ export default function useDownloadResponse(
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
downloadIcon.value = "download"
|
||||
}, 1000)
|
||||
}
|
||||
return {
|
||||
|
||||
223
packages/hoppscotch-app/helpers/realtime/MQTTConnection.ts
Normal file
223
packages/hoppscotch-app/helpers/realtime/MQTTConnection.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import Paho, { ConnectionOptions } from "paho-mqtt"
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
|
||||
|
||||
export type MQTTMessage = { topic: string; message: string }
|
||||
export type MQTTError =
|
||||
| { type: "CONNECTION_NOT_ESTABLISHED"; value: unknown }
|
||||
| { type: "CONNECTION_LOST" }
|
||||
| { type: "CONNECTION_FAILED" }
|
||||
| { type: "SUBSCRIPTION_FAILED"; topic: string }
|
||||
| { type: "PUBLISH_ERROR"; topic: string; message: string }
|
||||
|
||||
export type MQTTEvent = { time: number } & (
|
||||
| { type: "CONNECTING" }
|
||||
| { type: "CONNECTED" }
|
||||
| { type: "MESSAGE_SENT"; message: MQTTMessage }
|
||||
| { type: "SUBSCRIBED"; topic: string }
|
||||
| { type: "SUBSCRIPTION_FAILED"; topic: string }
|
||||
| { type: "MESSAGE_RECEIVED"; message: MQTTMessage }
|
||||
| { type: "DISCONNECTED"; manual: boolean }
|
||||
| { type: "ERROR"; error: MQTTError }
|
||||
)
|
||||
|
||||
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
|
||||
|
||||
export class MQTTConnection {
|
||||
subscriptionState$ = new BehaviorSubject<boolean>(false)
|
||||
connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
|
||||
event$: Subject<MQTTEvent> = new Subject()
|
||||
|
||||
private mqttClient: Paho.Client | undefined
|
||||
private manualDisconnect = false
|
||||
|
||||
private addEvent(event: MQTTEvent) {
|
||||
this.event$.next(event)
|
||||
}
|
||||
|
||||
connect(url: string, username: string, password: string) {
|
||||
try {
|
||||
this.connectionState$.next("CONNECTING")
|
||||
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "CONNECTING",
|
||||
})
|
||||
|
||||
const parseUrl = new URL(url)
|
||||
const { hostname, pathname, port } = parseUrl
|
||||
this.mqttClient = new Paho.Client(
|
||||
`${hostname + (pathname !== "/" ? pathname : "")}`,
|
||||
port !== "" ? Number(port) : 8081,
|
||||
"hoppscotch"
|
||||
)
|
||||
const connectOptions: ConnectionOptions = {
|
||||
onSuccess: this.onConnectionSuccess.bind(this),
|
||||
onFailure: this.onConnectionFailure.bind(this),
|
||||
useSSL: parseUrl.protocol !== "ws:",
|
||||
}
|
||||
if (username !== "") {
|
||||
connectOptions.userName = username
|
||||
}
|
||||
if (password !== "") {
|
||||
connectOptions.password = password
|
||||
}
|
||||
this.mqttClient.connect(connectOptions)
|
||||
this.mqttClient.onConnectionLost = this.onConnectionLost.bind(this)
|
||||
this.mqttClient.onMessageArrived = this.onMessageArrived.bind(this)
|
||||
} catch (e) {
|
||||
this.handleError(e)
|
||||
}
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "mqtt",
|
||||
})
|
||||
}
|
||||
|
||||
onConnectionFailure() {
|
||||
this.connectionState$.next("DISCONNECTED")
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type: "CONNECTION_FAILED",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onConnectionSuccess() {
|
||||
this.connectionState$.next("CONNECTED")
|
||||
this.addEvent({
|
||||
type: "CONNECTED",
|
||||
time: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
onConnectionLost() {
|
||||
this.connectionState$.next("DISCONNECTED")
|
||||
if (this.manualDisconnect) {
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "DISCONNECTED",
|
||||
manual: this.manualDisconnect,
|
||||
})
|
||||
} else {
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type: "CONNECTION_LOST",
|
||||
},
|
||||
})
|
||||
}
|
||||
this.manualDisconnect = false
|
||||
this.subscriptionState$.next(false)
|
||||
}
|
||||
|
||||
onMessageArrived({
|
||||
payloadString: message,
|
||||
destinationName: topic,
|
||||
}: {
|
||||
payloadString: string
|
||||
destinationName: string
|
||||
}) {
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "MESSAGE_RECEIVED",
|
||||
message: {
|
||||
topic,
|
||||
message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private handleError(error: unknown) {
|
||||
this.disconnect()
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type: "CONNECTION_NOT_ESTABLISHED",
|
||||
value: error,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
publish(topic: string, message: string) {
|
||||
if (this.connectionState$.value === "DISCONNECTED") return
|
||||
|
||||
try {
|
||||
// it was publish
|
||||
this.mqttClient?.send(topic, message, 0, false)
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "MESSAGE_SENT",
|
||||
message: {
|
||||
topic,
|
||||
message,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type: "PUBLISH_ERROR",
|
||||
topic,
|
||||
message,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(topic: string) {
|
||||
try {
|
||||
this.mqttClient?.subscribe(topic, {
|
||||
onSuccess: this.usubSuccess.bind(this, topic),
|
||||
onFailure: this.usubFailure.bind(this, topic),
|
||||
})
|
||||
} catch (e) {
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type: "SUBSCRIPTION_FAILED",
|
||||
topic,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
usubSuccess(topic: string) {
|
||||
this.subscriptionState$.next(!this.subscriptionState$.value)
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "SUBSCRIBED",
|
||||
topic,
|
||||
})
|
||||
}
|
||||
|
||||
usubFailure(topic: string) {
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type: "SUBSCRIPTION_FAILED",
|
||||
topic,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
unsubscribe(topic: string) {
|
||||
this.mqttClient?.unsubscribe(topic, {
|
||||
onSuccess: this.usubSuccess.bind(this, topic),
|
||||
onFailure: this.usubFailure.bind(this, topic),
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.manualDisconnect = true
|
||||
this.mqttClient?.disconnect()
|
||||
this.connectionState$.next("DISCONNECTED")
|
||||
}
|
||||
}
|
||||
84
packages/hoppscotch-app/helpers/realtime/SIOClients.ts
Normal file
84
packages/hoppscotch-app/helpers/realtime/SIOClients.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import wildcard from "socketio-wildcard"
|
||||
import ClientV2 from "socket.io-client-v2"
|
||||
import { io as ClientV4, Socket as SocketV4 } from "socket.io-client-v4"
|
||||
import { io as ClientV3, Socket as SocketV3 } from "socket.io-client-v3"
|
||||
|
||||
type Options = {
|
||||
path: string
|
||||
auth: {
|
||||
token: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
type PossibleEvent =
|
||||
| "connect"
|
||||
| "connect_error"
|
||||
| "reconnect_error"
|
||||
| "error"
|
||||
| "disconnect"
|
||||
| "*"
|
||||
|
||||
export interface SIOClient {
|
||||
connect(url: string, opts?: Options): void
|
||||
on(event: PossibleEvent, cb: (data: any) => void): void
|
||||
emit(event: string, data: any, cb: (data: any) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export class SIOClientV4 implements SIOClient {
|
||||
private client: SocketV4 | undefined
|
||||
connect(url: string, opts?: Options) {
|
||||
this.client = ClientV4(url, opts)
|
||||
}
|
||||
|
||||
on(event: PossibleEvent, cb: (data: any) => void) {
|
||||
this.client?.on(event, cb)
|
||||
}
|
||||
|
||||
emit(event: string, data: any, cb: (data: any) => void): void {
|
||||
this.client?.emit(event, data, cb)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class SIOClientV3 implements SIOClient {
|
||||
private client: SocketV3 | undefined
|
||||
connect(url: string, opts?: Options) {
|
||||
this.client = ClientV3(url, opts)
|
||||
}
|
||||
|
||||
on(event: PossibleEvent, cb: (data: any) => void): void {
|
||||
this.client?.on(event, cb)
|
||||
}
|
||||
|
||||
emit(event: string, data: any, cb: (data: any) => void): void {
|
||||
this.client?.emit(event, data, cb)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class SIOClientV2 implements SIOClient {
|
||||
private client: any | undefined
|
||||
connect(url: string, opts?: Options) {
|
||||
this.client = new ClientV2(url, opts)
|
||||
wildcard(ClientV2.Manager)(this.client)
|
||||
}
|
||||
|
||||
on(event: PossibleEvent, cb: (data: any) => void): void {
|
||||
this.client?.on(event, cb)
|
||||
}
|
||||
|
||||
emit(event: string, data: any, cb: (data: any) => void): void {
|
||||
this.client?.emit(event, data, cb)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.client?.close()
|
||||
}
|
||||
}
|
||||
163
packages/hoppscotch-app/helpers/realtime/SIOConnection.ts
Normal file
163
packages/hoppscotch-app/helpers/realtime/SIOConnection.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
|
||||
import { SIOClientV2, SIOClientV3, SIOClientV4, SIOClient } from "./SIOClients"
|
||||
import { SIOClientVersion } from "~/newstore/SocketIOSession"
|
||||
|
||||
export const SOCKET_CLIENTS = {
|
||||
v2: SIOClientV2,
|
||||
v3: SIOClientV3,
|
||||
v4: SIOClientV4,
|
||||
} as const
|
||||
|
||||
type SIOAuth = { type: "None" } | { type: "Bearer"; token: string }
|
||||
|
||||
export type ConnectionOption = {
|
||||
url: string
|
||||
path: string
|
||||
clientVersion: SIOClientVersion
|
||||
auth: SIOAuth | undefined
|
||||
}
|
||||
|
||||
export type SIOMessage = {
|
||||
eventName: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
type SIOErrorType = "CONNECTION" | "RECONNECT_ERROR" | "UNKNOWN"
|
||||
export type SIOError = {
|
||||
type: SIOErrorType
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export type SIOEvent = { time: number } & (
|
||||
| { type: "CONNECTING" }
|
||||
| { type: "CONNECTED" }
|
||||
| { type: "MESSAGE_SENT"; message: SIOMessage }
|
||||
| { type: "MESSAGE_RECEIVED"; message: SIOMessage }
|
||||
| { type: "DISCONNECTED"; manual: boolean }
|
||||
| { type: "ERROR"; error: SIOError }
|
||||
)
|
||||
|
||||
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
|
||||
|
||||
export class SIOConnection {
|
||||
connectionState$: BehaviorSubject<ConnectionState>
|
||||
event$: Subject<SIOEvent> = new Subject()
|
||||
socket: SIOClient | undefined
|
||||
constructor() {
|
||||
this.connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
|
||||
}
|
||||
|
||||
private addEvent(event: SIOEvent) {
|
||||
this.event$.next(event)
|
||||
}
|
||||
|
||||
connect({ url, path, clientVersion, auth }: ConnectionOption) {
|
||||
this.connectionState$.next("CONNECTING")
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "CONNECTING",
|
||||
})
|
||||
try {
|
||||
this.socket = new SOCKET_CLIENTS[clientVersion]()
|
||||
|
||||
if (auth?.type === "Bearer") {
|
||||
this.socket.connect(url, {
|
||||
path,
|
||||
auth: {
|
||||
token: auth.token,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
this.socket.connect(url)
|
||||
}
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
this.connectionState$.next("CONNECTED")
|
||||
this.addEvent({
|
||||
type: "CONNECTED",
|
||||
time: Date.now(),
|
||||
})
|
||||
})
|
||||
|
||||
this.socket.on("*", ({ data }: { data: string[] }) => {
|
||||
const [eventName, message] = data
|
||||
this.addEvent({
|
||||
message: { eventName, value: message },
|
||||
type: "MESSAGE_RECEIVED",
|
||||
time: Date.now(),
|
||||
})
|
||||
})
|
||||
|
||||
this.socket.on("connect_error", (error: unknown) => {
|
||||
this.handleError(error, "CONNECTION")
|
||||
})
|
||||
|
||||
this.socket.on("reconnect_error", (error: unknown) => {
|
||||
this.handleError(error, "RECONNECT_ERROR")
|
||||
})
|
||||
|
||||
this.socket.on("error", (error: unknown) => {
|
||||
this.handleError(error, "UNKNOWN")
|
||||
})
|
||||
|
||||
this.socket.on("disconnect", () => {
|
||||
this.connectionState$.next("DISCONNECTED")
|
||||
this.addEvent({
|
||||
type: "DISCONNECTED",
|
||||
time: Date.now(),
|
||||
manual: true,
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
this.handleError(error, "CONNECTION")
|
||||
}
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "socketio",
|
||||
})
|
||||
}
|
||||
|
||||
private handleError(error: unknown, type: SIOErrorType) {
|
||||
this.disconnect()
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type,
|
||||
value: error,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
sendMessage(event: { message: string; eventName: string }) {
|
||||
if (this.connectionState$.value === "DISCONNECTED") return
|
||||
const { message, eventName } = event
|
||||
|
||||
this.socket?.emit(eventName, message, (data) => {
|
||||
// receive response from server
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "MESSAGE_RECEIVED",
|
||||
message: {
|
||||
eventName,
|
||||
value: data,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "MESSAGE_SENT",
|
||||
message: {
|
||||
eventName,
|
||||
value: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.socket?.close()
|
||||
this.connectionState$.next("DISCONNECTED")
|
||||
}
|
||||
}
|
||||
86
packages/hoppscotch-app/helpers/realtime/SSEConnection.ts
Normal file
86
packages/hoppscotch-app/helpers/realtime/SSEConnection.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
|
||||
|
||||
export type SSEEvent = { time: number } & (
|
||||
| { type: "STARTING" }
|
||||
| { type: "STARTED" }
|
||||
| { type: "MESSAGE_RECEIVED"; message: string }
|
||||
| { type: "STOPPED"; manual: boolean }
|
||||
| { type: "ERROR"; error: Event | null }
|
||||
)
|
||||
|
||||
export type ConnectionState = "STARTING" | "STARTED" | "STOPPED"
|
||||
|
||||
export class SSEConnection {
|
||||
connectionState$: BehaviorSubject<ConnectionState>
|
||||
event$: Subject<SSEEvent> = new Subject()
|
||||
sse: EventSource | undefined
|
||||
constructor() {
|
||||
this.connectionState$ = new BehaviorSubject<ConnectionState>("STOPPED")
|
||||
}
|
||||
|
||||
private addEvent(event: SSEEvent) {
|
||||
this.event$.next(event)
|
||||
}
|
||||
|
||||
start(url: string, eventType: string) {
|
||||
this.connectionState$.next("STARTING")
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "STARTING",
|
||||
})
|
||||
if (typeof EventSource !== "undefined") {
|
||||
try {
|
||||
this.sse = new EventSource(url)
|
||||
this.sse.onopen = () => {
|
||||
this.connectionState$.next("STARTED")
|
||||
this.addEvent({
|
||||
type: "STARTED",
|
||||
time: Date.now(),
|
||||
})
|
||||
}
|
||||
this.sse.onerror = this.handleError
|
||||
this.sse.addEventListener(eventType, ({ data }) => {
|
||||
this.addEvent({
|
||||
type: "MESSAGE_RECEIVED",
|
||||
message: data,
|
||||
time: Date.now(),
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
// A generic event type returned if anything goes wrong or browser doesn't support SSE
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventSource/error_event#event_type
|
||||
this.handleError(error as Event)
|
||||
}
|
||||
} else {
|
||||
this.addEvent({
|
||||
type: "ERROR",
|
||||
time: Date.now(),
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "sse",
|
||||
})
|
||||
}
|
||||
|
||||
private handleError(error: Event) {
|
||||
this.stop()
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.sse?.close()
|
||||
this.connectionState$.next("STOPPED")
|
||||
this.addEvent({
|
||||
type: "STOPPED",
|
||||
time: Date.now(),
|
||||
manual: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
102
packages/hoppscotch-app/helpers/realtime/WSConnection.ts
Normal file
102
packages/hoppscotch-app/helpers/realtime/WSConnection.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
|
||||
|
||||
export type WSErrorMessage = SyntaxError | Event
|
||||
|
||||
export type WSEvent = { time: number } & (
|
||||
| { type: "CONNECTING" }
|
||||
| { type: "CONNECTED" }
|
||||
| { type: "MESSAGE_SENT"; message: string }
|
||||
| { type: "MESSAGE_RECEIVED"; message: string }
|
||||
| { type: "DISCONNECTED"; manual: boolean }
|
||||
| { type: "ERROR"; error: WSErrorMessage }
|
||||
)
|
||||
|
||||
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
|
||||
|
||||
export class WSConnection {
|
||||
connectionState$: BehaviorSubject<ConnectionState>
|
||||
event$: Subject<WSEvent> = new Subject()
|
||||
socket: WebSocket | undefined
|
||||
|
||||
constructor() {
|
||||
this.connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
|
||||
}
|
||||
|
||||
private addEvent(event: WSEvent) {
|
||||
this.event$.next(event)
|
||||
}
|
||||
|
||||
connect(url: string, protocols: string[]) {
|
||||
try {
|
||||
this.connectionState$.next("CONNECTING")
|
||||
this.socket = new WebSocket(url, protocols)
|
||||
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "CONNECTING",
|
||||
})
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.connectionState$.next("CONNECTED")
|
||||
this.addEvent({
|
||||
type: "CONNECTED",
|
||||
time: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.connectionState$.next("DISCONNECTED")
|
||||
this.addEvent({
|
||||
type: "DISCONNECTED",
|
||||
time: Date.now(),
|
||||
manual: true,
|
||||
})
|
||||
}
|
||||
|
||||
this.socket.onmessage = ({ data }) => {
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "MESSAGE_RECEIVED",
|
||||
message: data,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// We will have SyntaxError if anything goes wrong with WebSocket constructor
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#exceptions
|
||||
this.handleError(error as SyntaxError)
|
||||
}
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "wss",
|
||||
})
|
||||
}
|
||||
|
||||
private handleError(error: WSErrorMessage) {
|
||||
this.disconnect()
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
sendMessage(event: { message: string; eventName: string }) {
|
||||
if (this.connectionState$.value === "DISCONNECTED") return
|
||||
const { message } = event
|
||||
this.socket?.send(message)
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "MESSAGE_SENT",
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.socket?.close()
|
||||
}
|
||||
}
|
||||
8
packages/hoppscotch-app/helpers/shortcodes/Shortcode.ts
Normal file
8
packages/hoppscotch-app/helpers/shortcodes/Shortcode.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Defines how a Shortcode is represented in the ShortcodeListAdapter
|
||||
*/
|
||||
export interface Shortcode {
|
||||
id: string
|
||||
request: string
|
||||
createdOn: Date
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import * as E from "fp-ts/Either"
|
||||
import { BehaviorSubject, Subscription } from "rxjs"
|
||||
import { GQLError, runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
|
||||
import {
|
||||
GetUserShortcodesQuery,
|
||||
GetUserShortcodesDocument,
|
||||
ShortcodeCreatedDocument,
|
||||
ShortcodeDeletedDocument,
|
||||
} from "../backend/graphql"
|
||||
import { BACKEND_PAGE_SIZE } from "../backend/helpers"
|
||||
import { Shortcode } from "./Shortcode"
|
||||
|
||||
export default class ShortcodeListAdapter {
|
||||
error$: BehaviorSubject<GQLError<string> | null>
|
||||
loading$: BehaviorSubject<boolean>
|
||||
shortcodes$: BehaviorSubject<GetUserShortcodesQuery["myShortcodes"]>
|
||||
hasMoreShortcodes$: BehaviorSubject<boolean>
|
||||
|
||||
private timeoutHandle: ReturnType<typeof setTimeout> | null
|
||||
private isDispose: boolean
|
||||
|
||||
private myShortcodesCreated: Subscription | null
|
||||
private myShortcodesRevoked: Subscription | null
|
||||
|
||||
constructor(deferInit: boolean = false) {
|
||||
this.error$ = new BehaviorSubject<GQLError<string> | null>(null)
|
||||
this.loading$ = new BehaviorSubject<boolean>(false)
|
||||
this.shortcodes$ = new BehaviorSubject<
|
||||
GetUserShortcodesQuery["myShortcodes"]
|
||||
>([])
|
||||
this.hasMoreShortcodes$ = new BehaviorSubject<boolean>(true)
|
||||
this.timeoutHandle = null
|
||||
this.isDispose = false
|
||||
this.myShortcodesCreated = null
|
||||
this.myShortcodesRevoked = null
|
||||
|
||||
if (!deferInit) this.initialize()
|
||||
}
|
||||
|
||||
unsubscribeSubscriptions() {
|
||||
this.myShortcodesCreated?.unsubscribe()
|
||||
this.myShortcodesRevoked?.unsubscribe()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.timeoutHandle) throw new Error(`Adapter already initialized`)
|
||||
if (this.isDispose) throw new Error(`Adapter has been disposed`)
|
||||
|
||||
this.fetchList()
|
||||
this.registerSubscriptions()
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
if (!this.timeoutHandle) throw new Error(`Adapter has not been initialized`)
|
||||
if (!this.isDispose) throw new Error(`Adapter has been disposed`)
|
||||
|
||||
this.isDispose = true
|
||||
clearTimeout(this.timeoutHandle)
|
||||
this.timeoutHandle = null
|
||||
this.unsubscribeSubscriptions()
|
||||
}
|
||||
|
||||
fetchList() {
|
||||
this.loadMore(true)
|
||||
}
|
||||
|
||||
async loadMore(forcedAttempt = false) {
|
||||
if (!this.hasMoreShortcodes$.value && !forcedAttempt) return
|
||||
|
||||
this.loading$.next(true)
|
||||
|
||||
const lastCodeID =
|
||||
this.shortcodes$.value.length > 0
|
||||
? this.shortcodes$.value[this.shortcodes$.value.length - 1].id
|
||||
: undefined
|
||||
|
||||
const result = await runGQLQuery({
|
||||
query: GetUserShortcodesDocument,
|
||||
variables: {
|
||||
cursor: lastCodeID,
|
||||
},
|
||||
})
|
||||
if (E.isLeft(result)) {
|
||||
this.error$.next(result.left)
|
||||
console.error(result.left)
|
||||
this.loading$.next(false)
|
||||
|
||||
throw new Error(`Failed fetching short codes list: ${result.left}`)
|
||||
}
|
||||
|
||||
const fetchedResult = result.right.myShortcodes
|
||||
|
||||
this.pushNewShortcodes(fetchedResult)
|
||||
|
||||
if (fetchedResult.length !== BACKEND_PAGE_SIZE) {
|
||||
this.hasMoreShortcodes$.next(false)
|
||||
}
|
||||
|
||||
this.loading$.next(false)
|
||||
}
|
||||
|
||||
private pushNewShortcodes(results: Shortcode[]) {
|
||||
const userShortcodes = this.shortcodes$.value
|
||||
|
||||
userShortcodes.push(...results)
|
||||
|
||||
this.shortcodes$.next(userShortcodes)
|
||||
}
|
||||
|
||||
private createShortcode(shortcode: Shortcode) {
|
||||
const userShortcodes = this.shortcodes$.value
|
||||
|
||||
userShortcodes.unshift(shortcode)
|
||||
|
||||
this.shortcodes$.next(userShortcodes)
|
||||
}
|
||||
|
||||
private deleteShortcode(codeId: string) {
|
||||
const newShortcodes = this.shortcodes$.value.filter(
|
||||
({ id }) => id !== codeId
|
||||
)
|
||||
|
||||
this.shortcodes$.next(newShortcodes)
|
||||
}
|
||||
|
||||
private registerSubscriptions() {
|
||||
this.myShortcodesCreated = runGQLSubscription({
|
||||
query: ShortcodeCreatedDocument,
|
||||
}).subscribe((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
console.error(result.left)
|
||||
throw new Error(`Shortcode Create Error ${result.left}`)
|
||||
}
|
||||
|
||||
this.createShortcode(result.right.myShortcodesCreated)
|
||||
})
|
||||
|
||||
this.myShortcodesRevoked = runGQLSubscription({
|
||||
query: ShortcodeDeletedDocument,
|
||||
}).subscribe((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
console.error(result.left)
|
||||
throw new Error(`Shortcode Delete Error ${result.left}`)
|
||||
}
|
||||
|
||||
this.deleteShortcode(result.right.myShortcodesRevoked.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { AxiosRequestConfig } from "axios"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
@@ -15,12 +16,42 @@ export const hasFirefoxExtensionInstalled = () =>
|
||||
hasExtensionInstalled() && browserIsFirefox()
|
||||
|
||||
export const cancelRunningExtensionRequest = () => {
|
||||
if (
|
||||
hasExtensionInstalled() &&
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest
|
||||
) {
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest()
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRunningRequest()
|
||||
}
|
||||
|
||||
export const defineSubscribableObject = <T extends object>(obj: T) => {
|
||||
const proxyObject = {
|
||||
...obj,
|
||||
_subscribers: {} as {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[key in keyof T]?: ((...args: any[]) => any)[]
|
||||
},
|
||||
subscribe(prop: keyof T, func: (...args: any[]) => any): void {
|
||||
if (Array.isArray(this._subscribers[prop])) {
|
||||
this._subscribers[prop]?.push(func)
|
||||
} else {
|
||||
this._subscribers[prop] = [func]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type SubscribableProxyObject = typeof proxyObject
|
||||
|
||||
return new Proxy(proxyObject, {
|
||||
set(obj, prop, newVal) {
|
||||
obj[prop as keyof SubscribableProxyObject] = newVal
|
||||
|
||||
const currentSubscribers = obj._subscribers[prop as keyof T]
|
||||
|
||||
if (Array.isArray(currentSubscribers)) {
|
||||
for (const subscriber of currentSubscribers) {
|
||||
subscriber(newVal)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const preProcessRequest = (req: AxiosRequestConfig): AxiosRequestConfig => {
|
||||
@@ -56,13 +87,20 @@ const extensionStrategy: NetworkStrategy = (req) =>
|
||||
|
||||
// Run the request
|
||||
TE.bind("response", ({ processedReq }) =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({
|
||||
...processedReq,
|
||||
wantsBinary: true,
|
||||
}) as Promise<NetworkResponse>,
|
||||
(err) => err as any
|
||||
pipe(
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__,
|
||||
O.fromNullable,
|
||||
TE.fromOption(() => "NO_PW_EXT_HOOK" as const),
|
||||
TE.chain((extensionHook) =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
extensionHook.sendRequest({
|
||||
...processedReq,
|
||||
wantsBinary: true,
|
||||
}),
|
||||
(err) => err as any
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
@@ -122,13 +122,6 @@ describe("cancelRunningExtensionRequest", () => {
|
||||
cancelRunningExtensionRequest()
|
||||
expect(cancelFunc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not cancel request if extension installed but function not present", () => {
|
||||
global.__POSTWOMAN_EXTENSION_HOOK__ = {}
|
||||
|
||||
cancelRunningExtensionRequest()
|
||||
expect(cancelFunc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extensionStrategy", () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export type HoppRealtimeLogLine = {
|
||||
prefix?: string
|
||||
payload: string
|
||||
source: string
|
||||
color?: string
|
||||
ts: string
|
||||
ts: number | undefined
|
||||
}
|
||||
|
||||
export type HoppRealtimeLog = HoppRealtimeLogLine[]
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
parseBodyEnvVariables,
|
||||
parseRawKeyValueEntries,
|
||||
Environment,
|
||||
HoppRESTHeader,
|
||||
HoppRESTParam,
|
||||
} from "@hoppscotch/data"
|
||||
import { arrayFlatMap, arraySort } from "../functional/array"
|
||||
import { toFormData } from "../functional/formData"
|
||||
@@ -29,6 +31,146 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
|
||||
effectiveFinalBody: FormData | string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers that can be generated by authorization config of the request
|
||||
* @param req Request to check
|
||||
* @param envVars Currently active environment variables
|
||||
* @returns The list of headers
|
||||
*/
|
||||
const getComputedAuthHeaders = (
|
||||
req: HoppRESTRequest,
|
||||
envVars: Environment["variables"]
|
||||
) => {
|
||||
// If Authorization header is also being user-defined, that takes priority
|
||||
if (req.headers.find((h) => h.key.toLowerCase() === "authorization"))
|
||||
return []
|
||||
|
||||
if (!req.auth.authActive) return []
|
||||
|
||||
const headers: HoppRESTHeader[] = []
|
||||
|
||||
// TODO: Support a better b64 implementation than btoa ?
|
||||
if (req.auth.authType === "basic") {
|
||||
const username = parseTemplateString(req.auth.username, envVars)
|
||||
const password = parseTemplateString(req.auth.password, envVars)
|
||||
|
||||
headers.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Basic ${btoa(`${username}:${password}`)}`,
|
||||
})
|
||||
} else if (
|
||||
req.auth.authType === "bearer" ||
|
||||
req.auth.authType === "oauth-2"
|
||||
) {
|
||||
headers.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${parseTemplateString(req.auth.token, envVars)}`,
|
||||
})
|
||||
} else if (req.auth.authType === "api-key") {
|
||||
const { key, value, addTo } = req.auth
|
||||
|
||||
if (addTo === "Headers") {
|
||||
headers.push({
|
||||
active: true,
|
||||
key: parseTemplateString(key, envVars),
|
||||
value: parseTemplateString(value, envVars),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers that can be generated by body config of the request
|
||||
* @param req Request to check
|
||||
* @returns The list of headers
|
||||
*/
|
||||
export const getComputedBodyHeaders = (
|
||||
req: HoppRESTRequest
|
||||
): HoppRESTHeader[] => {
|
||||
// If a content-type is already defined, that will override this
|
||||
if (
|
||||
req.headers.find(
|
||||
(req) => req.active && req.key.toLowerCase() === "content-type"
|
||||
)
|
||||
)
|
||||
return []
|
||||
|
||||
// Body should have a non-null content-type
|
||||
if (req.body.contentType === null) return []
|
||||
|
||||
return [
|
||||
{
|
||||
active: true,
|
||||
key: "content-type",
|
||||
value: req.body.contentType,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export type ComputedHeader = {
|
||||
source: "auth" | "body"
|
||||
header: HoppRESTHeader
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of headers that will be added during execution of the request
|
||||
* For e.g, Authorization headers maybe added if an Auth Mode is defined on REST
|
||||
* @param req The request to check
|
||||
* @param envVars The environment variables active
|
||||
* @returns The headers that are generated along with the source of that header
|
||||
*/
|
||||
export const getComputedHeaders = (
|
||||
req: HoppRESTRequest,
|
||||
envVars: Environment["variables"]
|
||||
): ComputedHeader[] => [
|
||||
...getComputedAuthHeaders(req, envVars).map((header) => ({
|
||||
source: "auth" as const,
|
||||
header,
|
||||
})),
|
||||
...getComputedBodyHeaders(req).map((header) => ({
|
||||
source: "body" as const,
|
||||
header,
|
||||
})),
|
||||
]
|
||||
|
||||
export type ComputedParam = {
|
||||
source: "auth"
|
||||
param: HoppRESTParam
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of params that will be added during execution of the request
|
||||
* For e.g, Authorization params (like API-key) maybe added if an Auth Mode is defined on REST
|
||||
* @param req The request to check
|
||||
* @param envVars The environment variables active
|
||||
* @returns The params that are generated along with the source of that header
|
||||
*/
|
||||
export const getComputedParams = (
|
||||
req: HoppRESTRequest,
|
||||
envVars: Environment["variables"]
|
||||
): ComputedParam[] => {
|
||||
// When this gets complex, its best to split this function off (like with getComputedHeaders)
|
||||
// API-key auth can be added to query params
|
||||
if (!req.auth.authActive) return []
|
||||
if (req.auth.authType !== "api-key") return []
|
||||
if (req.auth.addTo !== "Query params") return []
|
||||
|
||||
return [
|
||||
{
|
||||
source: "auth",
|
||||
param: {
|
||||
active: true,
|
||||
key: parseTemplateString(req.auth.key, envVars),
|
||||
value: parseTemplateString(req.auth.value, envVars),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Resolves environment variables in the body
|
||||
export const resolvesEnvsInBody = (
|
||||
body: HoppRESTReqBody,
|
||||
@@ -135,83 +277,29 @@ export function getEffectiveRESTRequest(
|
||||
): EffectiveHoppRESTRequest {
|
||||
const envVariables = [...environment.variables, ...getGlobalVariables()]
|
||||
|
||||
const effectiveFinalHeaders = request.headers
|
||||
.filter(
|
||||
(x) =>
|
||||
x.key !== "" && // Remove empty keys
|
||||
x.active // Only active
|
||||
)
|
||||
.map((x) => ({
|
||||
// Parse out environment template strings
|
||||
const effectiveFinalHeaders = pipe(
|
||||
getComputedHeaders(request, envVariables).map((h) => h.header),
|
||||
A.concat(request.headers),
|
||||
A.filter((x) => x.active && x.key !== ""),
|
||||
A.map((x) => ({
|
||||
active: true,
|
||||
key: parseTemplateString(x.key, envVariables),
|
||||
value: parseTemplateString(x.value, envVariables),
|
||||
}))
|
||||
)
|
||||
|
||||
const effectiveFinalParams = request.params
|
||||
.filter(
|
||||
(x) =>
|
||||
x.key !== "" && // Remove empty keys
|
||||
x.active // Only active
|
||||
)
|
||||
.map((x) => ({
|
||||
const effectiveFinalParams = pipe(
|
||||
getComputedParams(request, envVariables).map((p) => p.param),
|
||||
A.concat(request.params),
|
||||
A.filter((x) => x.active && x.key !== ""),
|
||||
A.map((x) => ({
|
||||
active: true,
|
||||
key: parseTemplateString(x.key, envVariables),
|
||||
value: parseTemplateString(x.value, envVariables),
|
||||
}))
|
||||
|
||||
// Authentication
|
||||
if (request.auth.authActive) {
|
||||
// TODO: Support a better b64 implementation than btoa ?
|
||||
if (request.auth.authType === "basic") {
|
||||
const username = parseTemplateString(request.auth.username, envVariables)
|
||||
const password = parseTemplateString(request.auth.password, envVariables)
|
||||
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Basic ${btoa(`${username}:${password}`)}`,
|
||||
})
|
||||
} else if (
|
||||
request.auth.authType === "bearer" ||
|
||||
request.auth.authType === "oauth-2"
|
||||
) {
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${parseTemplateString(
|
||||
request.auth.token,
|
||||
envVariables
|
||||
)}`,
|
||||
})
|
||||
} else if (request.auth.authType === "api-key") {
|
||||
const { key, value, addTo } = request.auth
|
||||
if (addTo === "Headers") {
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: parseTemplateString(key, envVariables),
|
||||
value: parseTemplateString(value, envVariables),
|
||||
})
|
||||
} else if (addTo === "Query params") {
|
||||
effectiveFinalParams.push({
|
||||
active: true,
|
||||
key: parseTemplateString(key, envVariables),
|
||||
value: parseTemplateString(value, envVariables),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables)
|
||||
const contentTypeInHeader = effectiveFinalHeaders.find(
|
||||
(x) => x.key.toLowerCase() === "content-type"
|
||||
)
|
||||
if (request.body.contentType && !contentTypeInHeader?.value)
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: "content-type",
|
||||
value: request.body.contentType,
|
||||
})
|
||||
|
||||
return {
|
||||
...request,
|
||||
|
||||
@@ -14,6 +14,37 @@ export const knownContentTypes: Record<ValidContentTypes, Content> = {
|
||||
"text/plain": "plain",
|
||||
}
|
||||
|
||||
type ContentTypeTitle =
|
||||
| "request.content_type_titles.text"
|
||||
| "request.content_type_titles.structured"
|
||||
| "request.content_type_titles.others"
|
||||
|
||||
type SegmentedContentType = {
|
||||
title: ContentTypeTitle
|
||||
contentTypes: ValidContentTypes[]
|
||||
}
|
||||
|
||||
export const segmentedContentTypes: SegmentedContentType[] = [
|
||||
{
|
||||
title: "request.content_type_titles.text",
|
||||
contentTypes: [
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/hal+json",
|
||||
"application/vnd.api+json",
|
||||
"application/xml",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "request.content_type_titles.structured",
|
||||
contentTypes: ["application/x-www-form-urlencoded", "multipart/form-data"],
|
||||
},
|
||||
{
|
||||
title: "request.content_type_titles.others",
|
||||
contentTypes: ["text/html", "text/plain"],
|
||||
},
|
||||
]
|
||||
|
||||
export function isJSONContentType(contentType: string) {
|
||||
return /\bjson\b/i.test(contentType)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
const sourceEmojis = {
|
||||
// Source used for info messages.
|
||||
info: "\tℹ️ [INFO]:\t",
|
||||
// Source used for client to server messages.
|
||||
client: "\t⬅️ [SENT]:\t",
|
||||
// Source used for server to client messages.
|
||||
server: "\t➡️ [RECEIVED]:\t",
|
||||
}
|
||||
|
||||
export function getSourcePrefix(source: keyof typeof sourceEmojis) {
|
||||
return sourceEmojis[source]
|
||||
}
|
||||
Reference in New Issue
Block a user