refactor: move persistence logic into a dedicated service (#3493)

This commit is contained in:
James George
2023-11-29 22:40:26 +05:30
committed by GitHub
parent 144d14ab5b
commit 60bfb6fe2c
18 changed files with 3179 additions and 647 deletions

View File

@@ -57,7 +57,7 @@ module.exports = {
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
// window.localStorage block
@@ -66,7 +66,7 @@ module.exports = {
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
},

View File

@@ -92,6 +92,7 @@
"url": "^0.11.1",
"util": "^0.12.5",
"uuid": "^9.0.0",
"verzod": "^0.2.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-pdf-embed": "^1.1.6",
@@ -143,19 +144,19 @@
"eslint-plugin-vue": "^9.17.0",
"glob": "^10.3.10",
"npm-run-all": "^4.1.5",
"openapi-types": "^12.1.3",
"postcss": "^8.4.23",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.2",
"vite-plugin-fonts": "^0.6.0",
"openapi-types": "^12.1.3",
"rollup-plugin-polyfill-node": "^0.12.0",
"sass": "^1.66.0",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.6",
"unplugin-fonts": "^1.0.3",
"unplugin-icons": "^0.16.5",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.9",
"vite-plugin-checker": "^0.6.1",
"vite-plugin-fonts": "^0.6.0",
"vite-plugin-html-config": "^1.0.11",
"vite-plugin-inspect": "^0.7.38",
"vite-plugin-pages": "^0.31.0",

View File

@@ -47,14 +47,15 @@
</template>
<script setup lang="ts">
import { Splitpanes, Pane } from "splitpanes"
import { Pane, Splitpanes } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { computed, useSlots, ref } from "vue"
import { useSetting } from "@composables/settings"
import { setLocalConfig, getLocalConfig } from "~/newstore/localpersistence"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { useService } from "dioc/vue"
import { computed, ref, useSlots } from "vue"
import { PersistenceService } from "~/services/persistence"
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
@@ -67,6 +68,8 @@ const SIDEBAR = useSetting("SIDEBAR")
const slots = useSlots()
const persistenceService = useService(PersistenceService)
const hasSidebar = computed(() => !!slots.sidebar)
const hasSecondary = computed(() => !!slots.secondary)
@@ -96,7 +99,7 @@ if (!COLUMN_LAYOUT.value) {
function setPaneEvent(event: PaneEvent[], type: "vertical" | "horizontal") {
if (!props.layoutId) return
const storageKey = `${props.layoutId}-pane-config-${type}`
setLocalConfig(storageKey, JSON.stringify(event))
persistenceService.setLocalConfig(storageKey, JSON.stringify(event))
}
function populatePaneEvent() {
@@ -119,7 +122,7 @@ function populatePaneEvent() {
function getPaneData(type: "vertical" | "horizontal"): PaneEvent[] | null {
const storageKey = `${props.layoutId}-pane-config-${type}`
const paneEvent = getLocalConfig(storageKey)
const paneEvent = persistenceService.getLocalConfig(storageKey)
if (!paneEvent) return null
return JSON.parse(paneEvent)
}

View File

@@ -111,20 +111,21 @@
<script setup lang="ts">
import { Ref, computed, onMounted, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useStreamSubscriber } from "@composables/stream"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { platform } from "~/platform"
import { setLocalConfig } from "~/newstore/localpersistence"
import IconEmail from "~icons/auth/email"
import IconGithub from "~icons/auth/github"
import IconGoogle from "~icons/auth/google"
import IconEmail from "~icons/auth/email"
import IconMicrosoft from "~icons/auth/microsoft"
import IconArrowLeft from "~icons/lucide/arrow-left"
import { useService } from "dioc/vue"
import { LoginItemDef } from "~/platform/auth"
import { PersistenceService } from "~/services/persistence"
defineProps<{
show: boolean
@@ -138,6 +139,8 @@ const { subscribeToStream } = useStreamSubscriber()
const t = useI18n()
const toast = useToast()
const persistenceService = useService(PersistenceService)
const form = {
email: "",
}
@@ -260,7 +263,7 @@ const signInWithEmail = async () => {
.signInWithEmail(form.email)
.then(() => {
mode.value = "email-sent"
setLocalConfig("emailForSignIn", form.email)
persistenceService.setLocalConfig("emailForSignIn", form.email)
})
.catch((e) => {
console.error(e)

View File

@@ -51,7 +51,7 @@
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { completePageProgress, startPageProgress } from "@modules/loadingbar"
import { completePageProgress, startPageProgress } from "~/modules/loadingbar"
import * as gql from "graphql"
import { clone } from "lodash-es"
import { computed, ref, watch } from "vue"

View File

@@ -1,14 +1,13 @@
import {
getLocalConfig,
setLocalConfig,
removeLocalConfig,
} from "~/newstore/localpersistence"
import { getService } from "~/modules/dioc"
import { PersistenceService } from "~/services/persistence"
import * as E from "fp-ts/Either"
import { z } from "zod"
const redirectUri = `${window.location.origin}/oauth`
const persistenceService = getService(PersistenceService)
// GENERAL HELPER FUNCTIONS
/**
@@ -190,17 +189,17 @@ const tokenRequest = async ({
accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint
}
// Store oauth information
setLocalConfig("tokenEndpoint", accessTokenUrl)
setLocalConfig("client_id", clientId)
setLocalConfig("client_secret", clientSecret)
persistenceService.setLocalConfig("tokenEndpoint", accessTokenUrl)
persistenceService.setLocalConfig("client_id", clientId)
persistenceService.setLocalConfig("client_secret", clientSecret)
// Create and store a random state value
const state = generateRandomString()
setLocalConfig("pkce_state", state)
persistenceService.setLocalConfig("pkce_state", state)
// Create and store a new PKCE codeVerifier (the plaintext random secret)
const codeVerifier = generateRandomString()
setLocalConfig("pkce_codeVerifier", codeVerifier)
persistenceService.setLocalConfig("pkce_codeVerifier", codeVerifier)
// Hash and base64-urlencode the secret to use as the challenge
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier)
@@ -244,14 +243,14 @@ const handleOAuthRedirect = async () => {
// If the server returned an authorization code, attempt to exchange it for an access token
// Verify state matches what we set at the beginning
if (getLocalConfig("pkce_state") !== queryParams.state) {
if (persistenceService.getLocalConfig("pkce_state") !== queryParams.state) {
return E.left("INVALID_STATE" as const)
}
const tokenEndpoint = getLocalConfig("tokenEndpoint")
const clientID = getLocalConfig("client_id")
const clientSecret = getLocalConfig("client_secret")
const codeVerifier = getLocalConfig("pkce_codeVerifier")
const tokenEndpoint = persistenceService.getLocalConfig("tokenEndpoint")
const clientID = persistenceService.getLocalConfig("client_id")
const clientSecret = persistenceService.getLocalConfig("client_secret")
const codeVerifier = persistenceService.getLocalConfig("pkce_codeVerifier")
if (!tokenEndpoint) {
return E.left("NO_TOKEN_ENDPOINT" as const)
@@ -303,11 +302,11 @@ const handleOAuthRedirect = async () => {
}
const clearPKCEState = () => {
removeLocalConfig("pkce_state")
removeLocalConfig("pkce_codeVerifier")
removeLocalConfig("tokenEndpoint")
removeLocalConfig("client_id")
removeLocalConfig("client_secret")
persistenceService.removeLocalConfig("pkce_state")
persistenceService.removeLocalConfig("pkce_codeVerifier")
persistenceService.removeLocalConfig("tokenEndpoint")
persistenceService.removeLocalConfig("client_id")
persistenceService.removeLocalConfig("client_secret")
}
export { tokenRequest, handleOAuthRedirect }

View File

@@ -1,20 +1,21 @@
import { HOPP_MODULES } from "@modules/."
import { createApp } from "vue"
import { PlatformDef, setPlatformDef } from "./platform"
import { setupLocalPersistence } from "./newstore/localpersistence"
import { performMigrations } from "./helpers/migrations"
import { initializeApp } from "./helpers/app"
import { initBackendGQLClient } from "./helpers/backend/GQLClient"
import { HOPP_MODULES } from "@modules/."
import { performMigrations } from "./helpers/migrations"
import { PlatformDef, setPlatformDef } from "./platform"
import "../assets/scss/tailwind.scss"
import "../assets/themes/themes.scss"
import "../assets/scss/styles.scss"
import "nprogress/nprogress.css"
import "@fontsource-variable/inter"
import "@fontsource-variable/material-symbols-rounded"
import "@fontsource-variable/roboto-mono"
import "nprogress/nprogress.css"
import "../assets/scss/styles.scss"
import "../assets/scss/tailwind.scss"
import "../assets/themes/themes.scss"
import App from "./App.vue"
import { getService } from "./modules/dioc"
import { PersistenceService } from "./services/persistence"
export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
setPlatformDef(platformDef)
@@ -24,12 +25,15 @@ export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
// Some basic work that needs to be done before module inits even
initBackendGQLClient()
initializeApp()
setupLocalPersistence()
performMigrations()
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
platformDef.addedHoppModules?.forEach((mod) => mod.onVueAppInit?.(app))
// TODO: Explore possibilities of moving this invocation to the service constructor
// `toast` was coming up as `null` in the previous attempts
getService(PersistenceService).setupLocalPersistence()
performMigrations()
app.mount(el)
console.info(

View File

@@ -61,19 +61,21 @@
</template>
<script setup lang="ts">
import { computed, onBeforeMount, onMounted, ref, watch } from "vue"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
import { RouterView, useRouter } from "vue-router"
import { useSetting } from "@composables/settings"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { useService } from "dioc/vue"
import { Pane, Splitpanes } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
import { computed, onBeforeMount, onMounted, ref, watch } from "vue"
import { RouterView, useRouter } from "vue-router"
import { defineActionHandler } from "~/helpers/actions"
import { hookKeybindingsListener } from "~/helpers/keybindings"
import { applySetting } from "~/newstore/settings"
import { getLocalConfig, setLocalConfig } from "~/newstore/localpersistence"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
import { platform } from "~/platform"
import { PersistenceService } from "~/services/persistence"
const router = useRouter()
@@ -90,6 +92,8 @@ const mdAndLarger = breakpoints.greater("md")
const toast = useToast()
const t = useI18n()
const persistenceService = useService(PersistenceService)
onBeforeMount(() => {
if (!mdAndLarger.value) {
rightSidebar.value = false
@@ -98,7 +102,8 @@ onBeforeMount(() => {
})
onMounted(() => {
const cookiesAllowed = getLocalConfig("cookiesAllowed") === "yes"
const cookiesAllowed =
persistenceService.getLocalConfig("cookiesAllowed") === "yes"
const platformAllowsCookiePrompts =
platform.platformFeatureFlags.promptAsUsingCookies ?? true
@@ -109,7 +114,7 @@ onMounted(() => {
{
text: `${t("action.learn_more")}`,
onClick: (_, toastObject) => {
setLocalConfig("cookiesAllowed", "yes")
persistenceService.setLocalConfig("cookiesAllowed", "yes")
toastObject.goAway(0)
window
.open("https://docs.hoppscotch.io/support/privacy", "_blank")
@@ -119,7 +124,7 @@ onMounted(() => {
{
text: `${t("action.dismiss")}`,
onClick: (_, toastObject) => {
setLocalConfig("cookiesAllowed", "yes")
persistenceService.setLocalConfig("cookiesAllowed", "yes")
toastObject.goAway(0)
},
},

View File

@@ -1,14 +1,15 @@
import * as R from "fp-ts/Record"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as R from "fp-ts/Record"
import { createI18n, I18n, I18nOptions } from "vue-i18n"
import { HoppModule } from "."
import languages from "../../languages.json"
import { throwError } from "~/helpers/functional/error"
import { getLocalConfig, setLocalConfig } from "~/newstore/localpersistence"
import { PersistenceService } from "~/services/persistence"
import { getService } from "./dioc"
/*
In context of this file, we have 2 main kinds of things.
@@ -44,6 +45,8 @@ type LanguagesDef = {
const FALLBACK_LANG_CODE = "en"
const persistenceService = getService(PersistenceService)
// TypeScript cannot understand dir is restricted to "ltr" or "rtl" yet, hence assertion
export const APP_LANGUAGES: LanguagesDef[] = languages as LanguagesDef[]
@@ -69,7 +72,7 @@ let i18nInstance: I18n<
const resolveCurrentLocale = () =>
pipe(
// Resolve from locale and make sure it is in languages
getLocalConfig("locale"),
persistenceService.getLocalConfig("locale"),
O.fromNullable,
O.filter((locale) =>
pipe(
@@ -118,7 +121,7 @@ export const changeAppLanguage = async (locale: string) => {
// TODO: Look into the type issues here
i18nInstance.global.locale.value = locale
setLocalConfig("locale", locale)
persistenceService.setLocalConfig("locale", locale)
}
/**
@@ -145,7 +148,7 @@ export default <HoppModule>{
const currentLocale = resolveCurrentLocale()
changeAppLanguage(currentLocale)
setLocalConfig("locale", currentLocale)
persistenceService.setLocalConfig("locale", currentLocale)
},
onBeforeRouteChange(to, _, router) {
// Convert old locale path format to new format

View File

@@ -1,22 +1,26 @@
import { usePreferredDark, useStorage } from "@vueuse/core"
import { App, computed, reactive, Ref, watch } from "vue"
import type { HoppBgColor } from "~/newstore/settings"
import { useSettingStatic } from "@composables/settings"
import { usePreferredDark, useStorage } from "@vueuse/core"
import { App, Ref, computed, reactive, watch } from "vue"
import type { HoppBgColor } from "~/newstore/settings"
import { PersistenceService } from "~/services/persistence"
import { HoppModule } from "."
import { hoppLocalConfigStorage } from "~/newstore/localpersistence"
import { getService } from "./dioc"
export type HoppColorMode = {
preference: HoppBgColor
value: Readonly<Exclude<HoppBgColor, "system">>
}
const persistenceService = getService(PersistenceService)
const applyColorMode = (app: App) => {
const [settingPref] = useSettingStatic("BG_COLOR")
const currentLocalPreference = useStorage<HoppBgColor>(
"nuxt-color-mode",
"system",
hoppLocalConfigStorage,
persistenceService.hoppLocalConfigStorage,
{
listenToStorageChanges: true,
}

View File

@@ -1,457 +0,0 @@
/* eslint-disable no-restricted-globals, no-restricted-syntax */
import { clone, assign, isEmpty } from "lodash-es"
import {
translateToNewRESTCollection,
translateToNewGQLCollection,
Environment,
} from "@hoppscotch/data"
import {
settingsStore,
bulkApplySettings,
getDefaultSettings,
applySetting,
HoppAccentColor,
HoppBgColor,
performSettingsDataMigrations,
} from "./settings"
import {
restHistoryStore,
graphqlHistoryStore,
setRESTHistoryEntries,
setGraphqlHistoryEntries,
translateToNewRESTHistory,
translateToNewGQLHistory,
} from "./history"
import {
restCollectionStore,
graphqlCollectionStore,
setGraphqlCollections,
setRESTCollections,
} from "./collections"
import {
replaceEnvironments,
environments$,
addGlobalEnvVariable,
setGlobalEnvVariables,
globalEnv$,
setSelectedEnvironmentIndex,
selectedEnvironmentIndex$,
} from "./environments"
import { WSRequest$, setWSRequest } from "./WebSocketSession"
import { SIORequest$, setSIORequest } from "./SocketIOSession"
import { SSERequest$, setSSERequest } from "./SSESession"
import { MQTTRequest$, setMQTTRequest } from "./MQTTSession"
import { bulkApplyLocalState, localStateStore } from "./localstate"
import { StorageLike, watchDebounced } from "@vueuse/core"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
import { z } from "zod"
import { CookieJarService } from "~/services/cookie-jar.service"
import { watch } from "vue"
function checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) {
const index = window.localStorage.getItem("selectedEnvIndex")
if (index) {
if (index === "-1") {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "NO_ENV_SELECTED",
})
)
} else if (Number(index) >= 0) {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "MY_ENV",
index: parseInt(index),
})
)
}
}
}
const vuexData = JSON.parse(window.localStorage.getItem("vuex") || "{}")
if (isEmpty(vuexData)) return
const { postwoman } = vuexData
if (!isEmpty(postwoman?.settings)) {
const settingsData = assign(clone(getDefaultSettings()), postwoman.settings)
window.localStorage.setItem("settings", JSON.stringify(settingsData))
delete postwoman.settings
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.collections) {
window.localStorage.setItem(
"collections",
JSON.stringify(postwoman.collections)
)
delete postwoman.collections
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.collectionsGraphql) {
window.localStorage.setItem(
"collectionsGraphql",
JSON.stringify(postwoman.collectionsGraphql)
)
delete postwoman.collectionsGraphql
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.environments) {
window.localStorage.setItem(
"environments",
JSON.stringify(postwoman.environments)
)
delete postwoman.environments
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (window.localStorage.getItem("THEME_COLOR")) {
const themeColor = window.localStorage.getItem("THEME_COLOR")
applySetting("THEME_COLOR", themeColor as HoppAccentColor)
window.localStorage.removeItem("THEME_COLOR")
}
if (window.localStorage.getItem("nuxt-color-mode")) {
const color = window.localStorage.getItem("nuxt-color-mode") as HoppBgColor
applySetting("BG_COLOR", color)
window.localStorage.removeItem("nuxt-color-mode")
}
}
function setupLocalStatePersistence() {
const localStateData = JSON.parse(
window.localStorage.getItem("localState") ?? "{}"
)
if (localStateData) bulkApplyLocalState(localStateData)
localStateStore.subject$.subscribe((state) => {
window.localStorage.setItem("localState", JSON.stringify(state))
})
}
function setupSettingsPersistence() {
const settingsData = JSON.parse(
window.localStorage.getItem("settings") || "{}"
)
const updatedSettings = settingsData
? performSettingsDataMigrations(settingsData)
: settingsData
if (updatedSettings) {
bulkApplySettings(updatedSettings)
}
settingsStore.subject$.subscribe((settings) => {
window.localStorage.setItem("settings", JSON.stringify(settings))
})
}
function setupHistoryPersistence() {
const restHistoryData = JSON.parse(
window.localStorage.getItem("history") || "[]"
).map(translateToNewRESTHistory)
const graphqlHistoryData = JSON.parse(
window.localStorage.getItem("graphqlHistory") || "[]"
).map(translateToNewGQLHistory)
setRESTHistoryEntries(restHistoryData)
setGraphqlHistoryEntries(graphqlHistoryData)
restHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("history", JSON.stringify(state))
})
graphqlHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("graphqlHistory", JSON.stringify(state))
})
}
const cookieSchema = z.record(z.array(z.string()))
function setupCookiesPersistence() {
const cookieJarService = getService(CookieJarService)
try {
const cookieData = JSON.parse(
window.localStorage.getItem("cookieJar") || "{}"
)
const parseResult = cookieSchema.safeParse(cookieData)
if (parseResult.success) {
for (const domain in parseResult.data) {
cookieJarService.bulkApplyCookiesToDomain(
parseResult.data[domain],
domain
)
}
}
} catch (e) {}
watch(cookieJarService.cookieJar, (cookieJar) => {
const data = JSON.stringify(Object.fromEntries(cookieJar.entries()))
window.localStorage.setItem("cookieJar", data)
})
}
function setupCollectionsPersistence() {
const restCollectionData = JSON.parse(
window.localStorage.getItem("collections") || "[]"
).map(translateToNewRESTCollection)
const graphqlCollectionData = JSON.parse(
window.localStorage.getItem("collectionsGraphql") || "[]"
).map(translateToNewGQLCollection)
setRESTCollections(restCollectionData)
setGraphqlCollections(graphqlCollectionData)
restCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("collections", JSON.stringify(state))
})
graphqlCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("collectionsGraphql", JSON.stringify(state))
})
}
function setupEnvironmentsPersistence() {
const environmentsData: Environment[] = JSON.parse(
window.localStorage.getItem("environments") || "[]"
)
// Check if a global env is defined and if so move that to globals
const globalIndex = environmentsData.findIndex(
(x) => x.name.toLowerCase() === "globals"
)
if (globalIndex !== -1) {
const globalEnv = environmentsData[globalIndex]
globalEnv.variables.forEach((variable) => addGlobalEnvVariable(variable))
// Remove global from environments
environmentsData.splice(globalIndex, 1)
// Just sync the changes manually
window.localStorage.setItem(
"environments",
JSON.stringify(environmentsData)
)
}
replaceEnvironments(environmentsData)
environments$.subscribe((envs) => {
window.localStorage.setItem("environments", JSON.stringify(envs))
})
}
function setupSelectedEnvPersistence() {
const selectedEnvIndex = JSON.parse(
window.localStorage.getItem("selectedEnvIndex") ?? "null"
)
// If there is a selected env index, set it to the store else set it to null
if (selectedEnvIndex) {
setSelectedEnvironmentIndex(selectedEnvIndex)
} else {
setSelectedEnvironmentIndex({
type: "NO_ENV_SELECTED",
})
}
selectedEnvironmentIndex$.subscribe((envIndex) => {
window.localStorage.setItem("selectedEnvIndex", JSON.stringify(envIndex))
})
}
function setupWebsocketPersistence() {
const request = JSON.parse(
window.localStorage.getItem("WebsocketRequest") || "null"
)
setWSRequest(request)
WSRequest$.subscribe((req) => {
window.localStorage.setItem("WebsocketRequest", JSON.stringify(req))
})
}
function setupSocketIOPersistence() {
const request = JSON.parse(
window.localStorage.getItem("SocketIORequest") || "null"
)
setSIORequest(request)
SIORequest$.subscribe((req) => {
window.localStorage.setItem("SocketIORequest", JSON.stringify(req))
})
}
function setupSSEPersistence() {
const request = JSON.parse(
window.localStorage.getItem("SSERequest") || "null"
)
setSSERequest(request)
SSERequest$.subscribe((req) => {
window.localStorage.setItem("SSERequest", JSON.stringify(req))
})
}
function setupMQTTPersistence() {
const request = JSON.parse(
window.localStorage.getItem("MQTTRequest") || "null"
)
setMQTTRequest(request)
MQTTRequest$.subscribe((req) => {
window.localStorage.setItem("MQTTRequest", JSON.stringify(req))
})
}
function setupGlobalEnvsPersistence() {
const globals: Environment["variables"] = JSON.parse(
window.localStorage.getItem("globalEnv") || "[]"
)
setGlobalEnvVariables(globals)
globalEnv$.subscribe((vars) => {
window.localStorage.setItem("globalEnv", JSON.stringify(vars))
})
}
// TODO: Graceful error handling ?
export function setupRESTTabsPersistence() {
const tabService = getService(RESTTabService)
try {
const state = window.localStorage.getItem("restTabState")
if (state) {
const data = JSON.parse(state)
tabService.loadTabsFromPersistedState(data)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
window.localStorage.getItem("restTabState")
)
}
watchDebounced(
tabService.persistableTabState,
(state) => {
window.localStorage.setItem("restTabState", JSON.stringify(state))
},
{ debounce: 500, deep: true }
)
}
function setupGQLTabsPersistence() {
const tabService = getService(GQLTabService)
try {
const state = window.localStorage.getItem("gqlTabState")
if (state) {
const data = JSON.parse(state)
tabService.loadTabsFromPersistedState(data)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
window.localStorage.getItem("gqlTabState")
)
}
watchDebounced(
tabService.persistableTabState,
(state) => {
window.localStorage.setItem("gqlTabState", JSON.stringify(state))
},
{ debounce: 500, deep: true }
)
}
export function setupLocalPersistence() {
checkAndMigrateOldSettings()
setupLocalStatePersistence()
setupSettingsPersistence()
setupRESTTabsPersistence()
setupGQLTabsPersistence()
setupHistoryPersistence()
setupCollectionsPersistence()
setupGlobalEnvsPersistence()
setupEnvironmentsPersistence()
setupSelectedEnvPersistence()
setupWebsocketPersistence()
setupSocketIOPersistence()
setupSSEPersistence()
setupMQTTPersistence()
setupCookiesPersistence()
}
/**
* Gets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function getLocalConfig(name: string) {
return window.localStorage.getItem(name)
}
/**
* Sets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function setLocalConfig(key: string, value: string) {
window.localStorage.setItem(key, value)
}
/**
* Clear config value in LocalStorage.
* @param key Key to be cleared
*/
export function removeLocalConfig(key: string) {
window.localStorage.removeItem(key)
}
/**
* The storage system we are using in the application.
* NOTE: This is a placeholder for being used in app.
* This entire redirection of localStorage is to allow for
* not refactoring the entire app code when we refactor when
* we are building the native (which may lack localStorage,
* or use a custom system)
*/
export const hoppLocalConfigStorage: StorageLike = localStorage

View File

@@ -0,0 +1,208 @@
import {
Environment,
HoppCollection,
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { HoppGQLDocument } from "~/helpers/graphql/document"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
import { SettingsDef, getDefaultSettings } from "~/newstore/settings"
import { PersistableTabState } from "~/services/tab"
type VUEX_DATA = {
postwoman: {
settings?: SettingsDef
collections?: HoppCollection<HoppRESTRequest>[]
collectionsGraphql?: HoppCollection<HoppGQLRequest>[]
environments?: Environment[]
}
}
const DEFAULT_SETTINGS = getDefaultSettings()
export const REST_COLLECTIONS_MOCK: HoppCollection<HoppRESTRequest>[] = [
{
v: 1,
name: "Echo",
folders: [],
requests: [
{
v: "1",
endpoint: "https://echo.hoppscotch.io",
name: "Echo test",
params: [],
headers: [],
method: "GET",
auth: { authType: "none", authActive: true },
preRequestScript: "",
testScript: "",
body: { contentType: null, body: null },
},
],
},
]
export const GQL_COLLECTIONS_MOCK: HoppCollection<HoppGQLRequest>[] = [
{
v: 1,
name: "Echo",
folders: [],
requests: [
{
v: 2,
name: "Echo test",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: '{\n "id": "1"\n}',
query: "query Request { url }",
auth: { authType: "none", authActive: true },
},
],
},
]
export const ENVIRONMENTS_MOCK: Environment[] = [
{
name: "globals",
variables: [
{
key: "test-global-key",
value: "test-global-value",
},
],
},
{ name: "Test", variables: [{ key: "test-key", value: "test-value" }] },
]
export const SELECTED_ENV_INDEX_MOCK = {
type: "MY_ENV",
index: 1,
}
export const WEBSOCKET_REQUEST_MOCK = {
endpoint: "wss://echo-websocket.hoppscotch.io",
protocols: [],
}
export const SOCKET_IO_REQUEST_MOCK = {
endpoint: "wss://echo-socketio.hoppscotch.io",
path: "/socket.io",
version: "v4",
}
export const SSE_REQUEST_MOCK = {
endpoint: "https://express-eventsource.herokuapp.com/events",
eventType: "data",
}
export const MQTT_REQUEST_MOCK = {
endpoint: "wss://test.mosquitto.org:8081",
clientID: "hoppscotch",
}
export const GLOBAL_ENV_MOCK: Environment["variables"] = [
{ key: "test-key", value: "test-value" },
]
export const VUEX_DATA_MOCK: VUEX_DATA = {
postwoman: {
settings: { ...DEFAULT_SETTINGS, THEME_COLOR: "purple" },
collections: REST_COLLECTIONS_MOCK,
collectionsGraphql: GQL_COLLECTIONS_MOCK,
environments: ENVIRONMENTS_MOCK,
},
}
export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [
{
v: 1,
request: {
auth: { authType: "none", authActive: true },
body: { contentType: null, body: null },
endpoint: "https://echo.hoppscotch.io",
headers: [],
method: "GET",
name: "Untitled",
params: [],
preRequestScript: "",
testScript: "",
v: "1",
},
responseMeta: { duration: 807, statusCode: 200 },
star: false,
updatedOn: new Date("2023-11-07T05:27:32.951Z"),
},
]
export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [
{
v: 1,
request: {
v: 2,
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
query: "query Request { url }",
headers: [],
variables: "",
auth: { authType: "none", authActive: true },
},
response: '{"data":{"url":"/graphql"}}',
star: false,
updatedOn: new Date("2023-11-07T05:28:21.073Z"),
},
]
export const GQL_TAB_STATE_MOCK: PersistableTabState<HoppGQLDocument> = {
lastActiveTabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc",
orderedDocs: [
{
tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc",
doc: {
request: {
v: 2,
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: '{\n "id": "1"\n}',
query: "query Request { url }",
auth: { authType: "none", authActive: true },
},
isDirty: true,
optionTabPreference: "query",
response: null,
},
},
],
}
export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRESTDocument> = {
lastActiveTabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa",
orderedDocs: [
{
tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa",
doc: {
request: {
v: "1",
endpoint: "https://echo.hoppscotch.io",
name: "Echo test",
params: [],
headers: [],
method: "GET",
auth: { authType: "none", authActive: true },
preRequestScript: "",
testScript: "",
body: { contentType: null, body: null },
},
isDirty: false,
saveContext: {
originLocation: "user-collection",
folderPath: "0",
requestIndex: 0,
},
response: null,
},
},
],
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,724 @@
/* eslint-disable no-restricted-globals, no-restricted-syntax */
import {
Environment,
translateToNewGQLCollection,
translateToNewRESTCollection,
} from "@hoppscotch/data"
import { StorageLike, watchDebounced } from "@vueuse/core"
import { Service } from "dioc"
import { assign, clone, isEmpty } from "lodash-es"
import { z } from "zod"
import { GQLTabService } from "~/services/tab/graphql"
import { RESTTabService } from "~/services/tab/rest"
import { useToast } from "~/composables/toast"
import { MQTTRequest$, setMQTTRequest } from "../../newstore/MQTTSession"
import { SSERequest$, setSSERequest } from "../../newstore/SSESession"
import { SIORequest$, setSIORequest } from "../../newstore/SocketIOSession"
import { WSRequest$, setWSRequest } from "../../newstore/WebSocketSession"
import {
graphqlCollectionStore,
restCollectionStore,
setGraphqlCollections,
setRESTCollections,
} from "../../newstore/collections"
import {
addGlobalEnvVariable,
environments$,
globalEnv$,
replaceEnvironments,
selectedEnvironmentIndex$,
setGlobalEnvVariables,
setSelectedEnvironmentIndex,
} from "../../newstore/environments"
import {
graphqlHistoryStore,
restHistoryStore,
setGraphqlHistoryEntries,
setRESTHistoryEntries,
translateToNewGQLHistory,
translateToNewRESTHistory,
} from "../../newstore/history"
import { bulkApplyLocalState, localStateStore } from "../../newstore/localstate"
import {
HoppAccentColor,
HoppBgColor,
applySetting,
bulkApplySettings,
getDefaultSettings,
performSettingsDataMigrations,
settingsStore,
} from "../../newstore/settings"
import {
ENVIRONMENTS_SCHEMA,
GLOBAL_ENV_SCHEMA,
GQL_COLLECTION_SCHEMA,
GQL_HISTORY_ENTRY_SCHEMA,
GQL_TAB_STATE_SCHEMA,
LOCAL_STATE_SCHEMA,
MQTT_REQUEST_SCHEMA,
NUXT_COLOR_MODE_SCHEMA,
REST_COLLECTION_SCHEMA,
REST_HISTORY_ENTRY_SCHEMA,
REST_TAB_STATE_SCHEMA,
SELECTED_ENV_INDEX_SCHEMA,
SETTINGS_SCHEMA,
SOCKET_IO_REQUEST_SCHEMA,
SSE_REQUEST_SCHEMA,
THEME_COLOR_SCHEMA,
VUEX_SCHEMA,
WEBSOCKET_REQUEST_SCHEMA,
} from "./validation-schemas"
/**
* This service compiles persistence logic across the codebase
*/
export class PersistenceService extends Service {
public static readonly ID = "PERSISTENCE_SERVICE"
private readonly restTabService = this.bind(RESTTabService)
private readonly gqlTabService = this.bind(GQLTabService)
public hoppLocalConfigStorage: StorageLike = localStorage
constructor() {
super()
}
private showErrorToast(localStorageKey: string) {
const toast = useToast()
toast.error(
`There's a mismatch with the expected schema for the value corresponding to ${localStorageKey} read from localStorage, keeping a backup in ${localStorageKey}-backup`
)
}
private checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) {
const index = window.localStorage.getItem("selectedEnvIndex")
if (index) {
if (index === "-1") {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "NO_ENV_SELECTED",
})
)
} else if (Number(index) >= 0) {
window.localStorage.setItem(
"selectedEnvIndex",
JSON.stringify({
type: "MY_ENV",
index: parseInt(index),
})
)
}
}
}
const vuexKey = "vuex"
let vuexData = JSON.parse(window.localStorage.getItem(vuexKey) || "{}")
if (isEmpty(vuexData)) return
// Validate data read from localStorage
const result = VUEX_SCHEMA.safeParse(vuexData)
if (result.success) {
vuexData = result.data
} else {
this.showErrorToast(vuexKey)
window.localStorage.setItem(`${vuexKey}-backup`, JSON.stringify(vuexData))
}
const { postwoman } = vuexData
if (!isEmpty(postwoman?.settings)) {
const settingsData = assign(
clone(getDefaultSettings()),
postwoman.settings
)
window.localStorage.setItem("settings", JSON.stringify(settingsData))
delete postwoman.settings
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
if (postwoman?.collections) {
window.localStorage.setItem(
"collections",
JSON.stringify(postwoman.collections)
)
delete postwoman.collections
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
if (postwoman?.collectionsGraphql) {
window.localStorage.setItem(
"collectionsGraphql",
JSON.stringify(postwoman.collectionsGraphql)
)
delete postwoman.collectionsGraphql
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
if (postwoman?.environments) {
window.localStorage.setItem(
"environments",
JSON.stringify(postwoman.environments)
)
delete postwoman.environments
window.localStorage.setItem(vuexKey, JSON.stringify(vuexData))
}
const themeColorKey = "THEME_COLOR"
let themeColorValue = window.localStorage.getItem(themeColorKey)
if (themeColorValue) {
// Validate data read from localStorage
const result = THEME_COLOR_SCHEMA.safeParse(themeColorValue)
if (result.success) {
themeColorValue = result.data
} else {
this.showErrorToast(themeColorKey)
window.localStorage.setItem(`${themeColorKey}-backup`, themeColorValue)
}
applySetting(themeColorKey, themeColorValue as HoppAccentColor)
window.localStorage.removeItem(themeColorKey)
}
const nuxtColorModeKey = "nuxt-color-mode"
let nuxtColorModeValue = window.localStorage.getItem(nuxtColorModeKey)
if (nuxtColorModeValue) {
// Validate data read from localStorage
const result = NUXT_COLOR_MODE_SCHEMA.safeParse(nuxtColorModeValue)
if (result.success) {
nuxtColorModeValue = result.data
} else {
this.showErrorToast(nuxtColorModeKey)
window.localStorage.setItem(
`${nuxtColorModeKey}-backup`,
nuxtColorModeValue
)
}
applySetting("BG_COLOR", nuxtColorModeValue as HoppBgColor)
window.localStorage.removeItem(nuxtColorModeKey)
}
}
public setupLocalStatePersistence() {
const localStateKey = "localState"
let localStateData = JSON.parse(
window.localStorage.getItem(localStateKey) ?? "{}"
)
// Validate data read from localStorage
const result = LOCAL_STATE_SCHEMA.safeParse(localStateData)
if (result.success) {
localStateData = result.data
} else {
this.showErrorToast(localStateKey)
window.localStorage.setItem(
`${localStateKey}-backup`,
JSON.stringify(localStateData)
)
}
if (localStateData) bulkApplyLocalState(localStateData)
localStateStore.subject$.subscribe((state) => {
window.localStorage.setItem(localStateKey, JSON.stringify(state))
})
}
private setupSettingsPersistence() {
const settingsKey = "settings"
let settingsData = JSON.parse(
window.localStorage.getItem(settingsKey) || "{}"
)
// Validate data read from localStorage
const result = SETTINGS_SCHEMA.safeParse(settingsData)
if (result.success) {
settingsData = result.data
} else {
this.showErrorToast(settingsKey)
window.localStorage.setItem(
`${settingsKey}-backup`,
JSON.stringify(settingsData)
)
}
const updatedSettings = settingsData
? performSettingsDataMigrations(settingsData)
: settingsData
if (updatedSettings) {
bulkApplySettings(updatedSettings)
}
settingsStore.subject$.subscribe((settings) => {
window.localStorage.setItem(settingsKey, JSON.stringify(settings))
})
}
private setupHistoryPersistence() {
const restHistoryKey = "history"
let restHistoryData = JSON.parse(
window.localStorage.getItem(restHistoryKey) || "[]"
)
const graphqlHistoryKey = "graphqlHistory"
let graphqlHistoryData = JSON.parse(
window.localStorage.getItem(graphqlHistoryKey) || "[]"
)
// Validate data read from localStorage
const restHistorySchemaParsedresult = z
.array(REST_HISTORY_ENTRY_SCHEMA)
.safeParse(restHistoryData)
if (restHistorySchemaParsedresult.success) {
restHistoryData = restHistorySchemaParsedresult.data
} else {
this.showErrorToast(restHistoryKey)
window.localStorage.setItem(
`${restHistoryKey}-backup`,
JSON.stringify(restHistoryData)
)
}
const gqlHistorySchemaParsedresult = z
.array(GQL_HISTORY_ENTRY_SCHEMA)
.safeParse(graphqlHistoryData)
if (gqlHistorySchemaParsedresult.success) {
graphqlHistoryData = gqlHistorySchemaParsedresult.data
} else {
this.showErrorToast(graphqlHistoryKey)
window.localStorage.setItem(
`${graphqlHistoryKey}-backup`,
JSON.stringify(graphqlHistoryData)
)
}
const translatedRestHistoryData = restHistoryData.map(
translateToNewRESTHistory
)
const translatedGraphqlHistoryData = graphqlHistoryData.map(
translateToNewGQLHistory
)
setRESTHistoryEntries(translatedRestHistoryData)
setGraphqlHistoryEntries(translatedGraphqlHistoryData)
restHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(restHistoryKey, JSON.stringify(state))
})
graphqlHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(graphqlHistoryKey, JSON.stringify(state))
})
}
private setupCollectionsPersistence() {
const restCollectionsKey = "collections"
let restCollectionsData = JSON.parse(
window.localStorage.getItem(restCollectionsKey) || "[]"
)
const graphqlCollectionsKey = "collectionsGraphql"
let graphqlCollectionsData = JSON.parse(
window.localStorage.getItem(graphqlCollectionsKey) || "[]"
)
// Validate data read from localStorage
const restCollectionsSchemaParsedresult = z
.array(REST_COLLECTION_SCHEMA)
.safeParse(restCollectionsData)
if (restCollectionsSchemaParsedresult.success) {
restCollectionsData = restCollectionsSchemaParsedresult.data
} else {
this.showErrorToast(restCollectionsKey)
window.localStorage.setItem(
`${restCollectionsKey}-backup`,
JSON.stringify(restCollectionsData)
)
}
const gqlCollectionsSchemaParsedresult = z
.array(GQL_COLLECTION_SCHEMA)
.safeParse(graphqlCollectionsData)
if (gqlCollectionsSchemaParsedresult.success) {
graphqlCollectionsData = gqlCollectionsSchemaParsedresult.data
} else {
this.showErrorToast(graphqlCollectionsKey)
window.localStorage.setItem(
`${graphqlCollectionsKey}-backup`,
JSON.stringify(graphqlCollectionsData)
)
}
const translatedRestCollectionsData = restCollectionsData.map(
translateToNewRESTCollection
)
const translatedGraphqlCollectionsData = graphqlCollectionsData.map(
translateToNewGQLCollection
)
setRESTCollections(translatedRestCollectionsData)
setGraphqlCollections(translatedGraphqlCollectionsData)
restCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(restCollectionsKey, JSON.stringify(state))
})
graphqlCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem(graphqlCollectionsKey, JSON.stringify(state))
})
}
private setupEnvironmentsPersistence() {
const environmentsKey = "environments"
let environmentsData: Environment[] = JSON.parse(
window.localStorage.getItem(environmentsKey) || "[]"
)
// Validate data read from localStorage
const result = ENVIRONMENTS_SCHEMA.safeParse(environmentsData)
if (result.success) {
environmentsData = result.data
} else {
this.showErrorToast(environmentsKey)
window.localStorage.setItem(
`${environmentsKey}-backup`,
JSON.stringify(environmentsData)
)
}
// Check if a global env is defined and if so move that to globals
const globalIndex = environmentsData.findIndex(
(x) => x.name.toLowerCase() === "globals"
)
if (globalIndex !== -1) {
const globalEnv = environmentsData[globalIndex]
globalEnv.variables.forEach((variable) => addGlobalEnvVariable(variable))
// Remove global from environments
environmentsData.splice(globalIndex, 1)
// Just sync the changes manually
window.localStorage.setItem(
environmentsKey,
JSON.stringify(environmentsData)
)
}
replaceEnvironments(environmentsData)
environments$.subscribe((envs) => {
window.localStorage.setItem(environmentsKey, JSON.stringify(envs))
})
}
private setupSelectedEnvPersistence() {
const selectedEnvIndexKey = "selectedEnvIndex"
let selectedEnvIndexValue = JSON.parse(
window.localStorage.getItem(selectedEnvIndexKey) ?? "null"
)
// Validate data read from localStorage
const result = SELECTED_ENV_INDEX_SCHEMA.safeParse(selectedEnvIndexValue)
if (result.success) {
selectedEnvIndexValue = result.data
} else {
this.showErrorToast(selectedEnvIndexKey)
window.localStorage.setItem(
`${selectedEnvIndexKey}-backup`,
JSON.stringify(selectedEnvIndexValue)
)
}
// If there is a selected env index, set it to the store else set it to null
if (selectedEnvIndexValue) {
setSelectedEnvironmentIndex(selectedEnvIndexValue)
} else {
setSelectedEnvironmentIndex({
type: "NO_ENV_SELECTED",
})
}
selectedEnvironmentIndex$.subscribe((envIndex) => {
window.localStorage.setItem(selectedEnvIndexKey, JSON.stringify(envIndex))
})
}
private setupWebsocketPersistence() {
const wsRequestKey = "WebsocketRequest"
let wsRequestData = JSON.parse(
window.localStorage.getItem(wsRequestKey) || "null"
)
// Validate data read from localStorage
const result = WEBSOCKET_REQUEST_SCHEMA.safeParse(wsRequestData)
if (result.success) {
wsRequestData = result.data
} else {
this.showErrorToast(wsRequestKey)
window.localStorage.setItem(
`${wsRequestKey}-backup`,
JSON.stringify(wsRequestData)
)
}
setWSRequest(wsRequestData)
WSRequest$.subscribe((req) => {
window.localStorage.setItem(wsRequestKey, JSON.stringify(req))
})
}
private setupSocketIOPersistence() {
const sioRequestKey = "SocketIORequest"
let sioRequestData = JSON.parse(
window.localStorage.getItem(sioRequestKey) || "null"
)
// Validate data read from localStorage
const result = SOCKET_IO_REQUEST_SCHEMA.safeParse(sioRequestData)
if (result.success) {
sioRequestData = result.data
} else {
this.showErrorToast(sioRequestKey)
window.localStorage.setItem(
`${sioRequestKey}-backup`,
JSON.stringify(sioRequestData)
)
}
setSIORequest(sioRequestData)
SIORequest$.subscribe((req) => {
window.localStorage.setItem(sioRequestKey, JSON.stringify(req))
})
}
private setupSSEPersistence() {
const sseRequestKey = "SSERequest"
let sseRequestData = JSON.parse(
window.localStorage.getItem(sseRequestKey) || "null"
)
// Validate data read from localStorage
const result = SSE_REQUEST_SCHEMA.safeParse(sseRequestData)
if (result.success) {
sseRequestData = result.data
} else {
this.showErrorToast(sseRequestKey)
window.localStorage.setItem(
`${sseRequestKey}-backup`,
JSON.stringify(sseRequestData)
)
}
setSSERequest(sseRequestData)
SSERequest$.subscribe((req) => {
window.localStorage.setItem(sseRequestKey, JSON.stringify(req))
})
}
private setupMQTTPersistence() {
const mqttRequestKey = "MQTTRequest"
let mqttRequestData = JSON.parse(
window.localStorage.getItem(mqttRequestKey) || "null"
)
// Validate data read from localStorage
const result = MQTT_REQUEST_SCHEMA.safeParse(mqttRequestData)
if (result.success) {
mqttRequestData = result.data
} else {
this.showErrorToast(mqttRequestKey)
window.localStorage.setItem(
`${mqttRequestKey}-backup`,
JSON.stringify(mqttRequestData)
)
}
setMQTTRequest(mqttRequestData)
MQTTRequest$.subscribe((req) => {
window.localStorage.setItem(mqttRequestKey, JSON.stringify(req))
})
}
private setupGlobalEnvsPersistence() {
const globalEnvKey = "globalEnv"
let globalEnvData: Environment["variables"] = JSON.parse(
window.localStorage.getItem(globalEnvKey) || "[]"
)
// Validate data read from localStorage
const result = GLOBAL_ENV_SCHEMA.safeParse(globalEnvData)
if (result.success) {
globalEnvData = result.data
} else {
this.showErrorToast(globalEnvKey)
window.localStorage.setItem(
`${globalEnvKey}-backup`,
JSON.stringify(globalEnvData)
)
}
setGlobalEnvVariables(globalEnvData)
globalEnv$.subscribe((vars) => {
window.localStorage.setItem(globalEnvKey, JSON.stringify(vars))
})
}
private setupGQLTabsPersistence() {
const gqlTabStateKey = "gqlTabState"
const gqlTabStateData = window.localStorage.getItem(gqlTabStateKey)
try {
if (gqlTabStateData) {
let parsedGqlTabStateData = JSON.parse(gqlTabStateData)
// Validate data read from localStorage
const result = GQL_TAB_STATE_SCHEMA.safeParse(parsedGqlTabStateData)
if (result.success) {
parsedGqlTabStateData = result.data
} else {
this.showErrorToast(gqlTabStateKey)
window.localStorage.setItem(
`${gqlTabStateKey}-backup`,
JSON.stringify(parsedGqlTabStateData)
)
}
this.gqlTabService.loadTabsFromPersistedState(parsedGqlTabStateData)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
gqlTabStateData
)
}
watchDebounced(
this.gqlTabService.persistableTabState,
(newGqlTabStateData) => {
window.localStorage.setItem(
gqlTabStateKey,
JSON.stringify(newGqlTabStateData)
)
},
{ debounce: 500, deep: true }
)
}
private setupRESTTabsPersistence() {
const restTabStateKey = "restTabState"
const restTabStateData = window.localStorage.getItem(restTabStateKey)
try {
if (restTabStateData) {
let parsedGqlTabStateData = JSON.parse(restTabStateData)
// Validate data read from localStorage
const result = REST_TAB_STATE_SCHEMA.safeParse(parsedGqlTabStateData)
if (result.success) {
parsedGqlTabStateData = result.data
} else {
this.showErrorToast(restTabStateKey)
window.localStorage.setItem(
`${restTabStateKey}-backup`,
JSON.stringify(parsedGqlTabStateData)
)
}
this.restTabService.loadTabsFromPersistedState(parsedGqlTabStateData)
}
} catch (e) {
console.error(
`Failed parsing persisted tab state, state:`,
restTabStateData
)
}
watchDebounced(
this.restTabService.persistableTabState,
(newRestTabStateData) => {
window.localStorage.setItem(
restTabStateKey,
JSON.stringify(newRestTabStateData)
)
},
{ debounce: 500, deep: true }
)
}
public setupLocalPersistence() {
this.checkAndMigrateOldSettings()
this.setupLocalStatePersistence()
this.setupSettingsPersistence()
this.setupRESTTabsPersistence()
this.setupGQLTabsPersistence()
this.setupHistoryPersistence()
this.setupCollectionsPersistence()
this.setupGlobalEnvsPersistence()
this.setupEnvironmentsPersistence()
this.setupSelectedEnvPersistence()
this.setupWebsocketPersistence()
this.setupSocketIOPersistence()
this.setupSSEPersistence()
this.setupMQTTPersistence()
}
/**
* Gets a value from localStorage
*
* NOTE: Use localStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to `PersistenceService`
*/
public getLocalConfig(name: string) {
return window.localStorage.getItem(name)
}
/**
* Sets a value in localStorage
*
* NOTE: Use localStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to `PersistenceService`
*/
public setLocalConfig(key: string, value: string) {
window.localStorage.setItem(key, value)
}
/**
* Clear config value in localStorage
*/
public removeLocalConfig(key: string) {
window.localStorage.removeItem(key)
}
}

View File

@@ -0,0 +1,470 @@
import {
Environment,
GQLHeader,
HoppGQLAuth,
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { entityReference } from "verzod"
import { z } from "zod"
import { HoppAccentColors, HoppBgColors } from "~/newstore/settings"
const ThemeColorSchema = z.enum([
"green",
"teal",
"blue",
"indigo",
"purple",
"yellow",
"orange",
"red",
"pink",
])
const BgColorSchema = z.enum(["system", "light", "dark", "black"])
const SettingsDefSchema = z.object({
syncCollections: z.boolean(),
syncHistory: z.boolean(),
syncEnvironments: z.boolean(),
PROXY_URL: z.string(),
CURRENT_INTERCEPTOR_ID: z.string(),
URL_EXCLUDES: z.object({
auth: z.boolean(),
httpUser: z.boolean(),
httpPassword: z.boolean(),
bearerToken: z.boolean(),
oauth2Token: z.boolean(),
}),
THEME_COLOR: ThemeColorSchema,
BG_COLOR: BgColorSchema,
TELEMETRY_ENABLED: z.boolean(),
EXPAND_NAVIGATION: z.boolean(),
SIDEBAR: z.boolean(),
SIDEBAR_ON_LEFT: z.boolean(),
COLUMN_LAYOUT: z.boolean(),
})
// Common properties shared across REST & GQL collections
const HoppCollectionSchemaCommonProps = z
.object({
v: z.number(),
name: z.string(),
id: z.optional(z.string()),
})
.strict()
const HoppRESTRequestSchema = entityReference(HoppRESTRequest)
const HoppGQLRequestSchema = entityReference(HoppGQLRequest)
// @ts-expect-error recursive schema
const HoppRESTCollectionSchema = HoppCollectionSchemaCommonProps.extend({
folders: z.array(z.lazy(() => HoppRESTCollectionSchema)),
requests: z.optional(z.array(HoppRESTRequestSchema)),
}).strict()
// @ts-expect-error recursive schema
const HoppGQLCollectionSchema = HoppCollectionSchemaCommonProps.extend({
folders: z.array(z.lazy(() => HoppGQLCollectionSchema)),
requests: z.optional(z.array(HoppGQLRequestSchema)),
}).strict()
export const VUEX_SCHEMA = z.object({
postwoman: z.optional(
z.object({
settings: z.optional(SettingsDefSchema),
//! Versioned entities
collections: z.optional(z.array(HoppRESTCollectionSchema)),
collectionsGraphql: z.optional(z.array(HoppGQLCollectionSchema)),
environments: z.optional(z.array(entityReference(Environment))),
})
),
})
export const THEME_COLOR_SCHEMA = z.enum(HoppAccentColors)
export const NUXT_COLOR_MODE_SCHEMA = z.enum(HoppBgColors)
export const LOCAL_STATE_SCHEMA = z.union([
z.object({}).strict(),
z
.object({
REMEMBERED_TEAM_ID: z.optional(z.string()),
})
.strict(),
])
export const SETTINGS_SCHEMA = z.union([
z.object({}).strict(),
SettingsDefSchema.extend({
EXTENSIONS_ENABLED: z.optional(z.boolean()),
PROXY_ENABLED: z.optional(z.boolean()),
}),
])
export const REST_HISTORY_ENTRY_SCHEMA = z
.object({
v: z.number(),
//! Versioned entity
request: HoppRESTRequestSchema,
responseMeta: z
.object({
duration: z.nullable(z.number()),
statusCode: z.nullable(z.number()),
})
.strict(),
star: z.boolean(),
id: z.optional(z.string()),
updatedOn: z.optional(z.union([z.date(), z.string()])),
})
.strict()
export const GQL_HISTORY_ENTRY_SCHEMA = z
.object({
v: z.number(),
//! Versioned entity
request: HoppGQLRequestSchema,
response: z.string(),
star: z.boolean(),
id: z.optional(z.string()),
updatedOn: z.optional(z.union([z.date(), z.string()])),
})
.strict()
export const REST_COLLECTION_SCHEMA = HoppRESTCollectionSchema
export const GQL_COLLECTION_SCHEMA = HoppGQLCollectionSchema
export const ENVIRONMENTS_SCHEMA = z.array(entityReference(Environment))
export const SELECTED_ENV_INDEX_SCHEMA = z.nullable(
z.discriminatedUnion("type", [
z
.object({
type: z.literal("NO_ENV_SELECTED"),
})
.strict(),
z
.object({
type: z.literal("MY_ENV"),
index: z.number(),
})
.strict(),
z.object({
type: z.literal("TEAM_ENV"),
teamID: z.string(),
teamEnvID: z.string(),
// ! Versioned entity
environment: entityReference(Environment),
}),
])
)
export const WEBSOCKET_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
protocols: z.array(
z
.object({
value: z.string(),
active: z.boolean(),
})
.strict()
),
})
.strict()
)
export const SOCKET_IO_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
path: z.string(),
version: z.union([z.literal("v4"), z.literal("v3"), z.literal("v2")]),
})
.strict()
)
export const SSE_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
eventType: z.string(),
})
.strict()
)
export const MQTT_REQUEST_SCHEMA = z.nullable(
z
.object({
endpoint: z.string(),
clientID: z.string(),
})
.strict()
)
export const GLOBAL_ENV_SCHEMA = z.union([
z.array(z.never()),
z.array(
z
.object({
key: z.string(),
value: z.string(),
})
.strict()
),
])
const OperationTypeSchema = z.enum([
"subscription",
"query",
"mutation",
"teardown",
])
const RunQueryOptionsSchema = z
.object({
name: z.optional(z.string()),
url: z.string(),
headers: z.array(GQLHeader),
query: z.string(),
variables: z.string(),
auth: HoppGQLAuth,
operationName: z.optional(z.string()),
operationType: OperationTypeSchema,
})
.strict()
const HoppGQLSaveContextSchema = z.nullable(
z.discriminatedUnion("originLocation", [
z
.object({
originLocation: z.literal("user-collection"),
folderPath: z.string(),
requestIndex: z.number(),
})
.strict(),
z
.object({
originLocation: z.literal("team-collection"),
requestID: z.string(),
teamID: z.optional(z.string()),
collectionID: z.optional(z.string()),
})
.strict(),
])
)
const GQLResponseEventSchema = z.array(
z
.object({
time: z.number(),
operationName: z.optional(z.string()),
operationType: OperationTypeSchema,
data: z.string(),
rawQuery: z.optional(RunQueryOptionsSchema),
})
.strict()
)
const validGqlOperations = [
"query",
"headers",
"variables",
"authorization",
] as const
export const GQL_TAB_STATE_SCHEMA = z
.object({
lastActiveTabID: z.string(),
orderedDocs: z.array(
z.object({
tabID: z.string(),
doc: z
.object({
// Versioned entity
request: entityReference(HoppGQLRequest),
isDirty: z.boolean(),
saveContext: z.optional(HoppGQLSaveContextSchema),
response: z.optional(z.nullable(GQLResponseEventSchema)),
responseTabPreference: z.optional(z.string()),
optionTabPreference: z.optional(z.enum(validGqlOperations)),
})
.strict(),
})
),
})
.strict()
const HoppTestExpectResultSchema = z
.object({
status: z.enum(["fail", "pass", "error"]),
message: z.string(),
})
.strict()
// @ts-expect-error recursive schema
const HoppTestDataSchema = z.lazy(() =>
z
.object({
description: z.string(),
expectResults: z.array(HoppTestExpectResultSchema),
tests: z.array(HoppTestDataSchema),
})
.strict()
)
const EnvironmentVariablesSchema = z
.object({
key: z.string(),
value: z.string(),
})
.strict()
const HoppTestResultSchema = z
.object({
tests: z.array(HoppTestDataSchema),
expectResults: z.array(HoppTestExpectResultSchema),
description: z.string(),
scriptError: z.boolean(),
envDiff: z
.object({
global: z
.object({
additions: z.array(EnvironmentVariablesSchema),
updations: z.array(
EnvironmentVariablesSchema.extend({ previousValue: z.string() })
),
deletions: z.array(EnvironmentVariablesSchema),
})
.strict(),
selected: z
.object({
additions: z.array(EnvironmentVariablesSchema),
updations: z.array(
EnvironmentVariablesSchema.extend({ previousValue: z.string() })
),
deletions: z.array(EnvironmentVariablesSchema),
})
.strict(),
})
.strict(),
})
.strict()
const HoppRESTResponseHeaderSchema = z
.object({
key: z.string(),
value: z.string(),
})
.strict()
const HoppRESTResponseSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("loading"),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
z
.object({
type: z.literal("fail"),
headers: z.array(HoppRESTResponseHeaderSchema),
body: z.instanceof(ArrayBuffer),
statusCode: z.number(),
meta: z
.object({
responseSize: z.number(),
responseDuration: z.number(),
})
.strict(),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
z
.object({
type: z.literal("network_fail"),
error: z.unknown(),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
z
.object({
type: z.literal("script_fail"),
error: z.instanceof(Error),
})
.strict(),
z
.object({
type: z.literal("success"),
headers: z.array(HoppRESTResponseHeaderSchema),
body: z.instanceof(ArrayBuffer),
statusCode: z.number(),
meta: z
.object({
responseSize: z.number(),
responseDuration: z.number(),
})
.strict(),
// !Versioned entity
req: HoppRESTRequestSchema,
})
.strict(),
])
const HoppRESTSaveContextSchema = z.nullable(
z.discriminatedUnion("originLocation", [
z
.object({
originLocation: z.literal("user-collection"),
folderPath: z.string(),
requestIndex: z.number(),
})
.strict(),
z
.object({
originLocation: z.literal("team-collection"),
requestID: z.string(),
teamID: z.optional(z.string()),
collectionID: z.optional(z.string()),
})
.strict(),
])
)
const validRestOperations = [
"params",
"bodyParams",
"headers",
"authorization",
"preRequestScript",
"tests",
] as const
export const REST_TAB_STATE_SCHEMA = z
.object({
lastActiveTabID: z.string(),
orderedDocs: z.array(
z.object({
tabID: z.string(),
doc: z
.object({
// !Versioned entity
request: entityReference(HoppRESTRequest),
isDirty: z.boolean(),
saveContext: z.optional(HoppRESTSaveContextSchema),
response: z.optional(z.nullable(HoppRESTResponseSchema)),
testResults: z.optional(z.nullable(HoppTestResultSchema)),
responseTabPreference: z.optional(z.string()),
optionTabPreference: z.optional(z.enum(validRestOperations)),
})
.strict(),
})
),
})
.strict()

View File

@@ -1,20 +1,18 @@
import axios from "axios"
import { getService } from "@hoppscotch/common/modules/dioc"
import {
AuthEvent,
AuthPlatformDef,
HoppUser,
} from "@hoppscotch/common/platform/auth"
import { BehaviorSubject, Subject } from "rxjs"
import {
getLocalConfig,
removeLocalConfig,
setLocalConfig,
} from "@hoppscotch/common/newstore/localpersistence"
import { Ref, ref, watch } from "vue"
import { open } from '@tauri-apps/api/shell'
import { Body, getClient } from '@tauri-apps/api/http'
PersistenceService
} from "@hoppscotch/common/services/persistence"
import { listen } from '@tauri-apps/api/event'
import { Store } from "tauri-plugin-store-api";
import { Body, getClient } from '@tauri-apps/api/http'
import { open } from '@tauri-apps/api/shell'
import { BehaviorSubject, Subject } from "rxjs"
import { Store } from "tauri-plugin-store-api"
import { Ref, ref, watch } from "vue"
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
@@ -22,6 +20,8 @@ export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
const persistenceService = getService(PersistenceService)
async function logout() {
let client = await getClient();
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
@@ -86,7 +86,7 @@ function setUser(user: HoppUser | null) {
currentUser$.next(user)
probableUser$.next(user)
setLocalConfig("login_state", JSON.stringify(user))
persistenceService.setLocalConfig("login_state", JSON.stringify(user))
}
async function setInitialUser() {
@@ -181,7 +181,7 @@ async function sendMagicLink(email: string) {
const res = await client.post(url, Body.json({ email }));
if (res.data && res.data.deviceIdentifier) {
setLocalConfig("deviceIdentifier", res.data.deviceIdentifier)
persistenceService.setLocalConfig("deviceIdentifier", res.data.deviceIdentifier)
} else {
throw new Error("test: does not get device identifier")
}
@@ -257,7 +257,7 @@ export const def: AuthPlatformDef = {
return null
},
async performAuthInit() {
const probableUser = JSON.parse(getLocalConfig("login_state") ?? "null")
const probableUser = JSON.parse(persistenceService.getLocalConfig("login_state") ?? "null")
probableUser$.next(probableUser)
await setInitialUser()
@@ -285,7 +285,7 @@ export const def: AuthPlatformDef = {
}
if (isNotNullOrUndefined(token)) {
setLocalConfig("verifyToken", token)
persistenceService.setLocalConfig("verifyToken", token)
await this.signInWithEmailLink("", "")
await setInitialUser()
}
@@ -327,7 +327,7 @@ export const def: AuthPlatformDef = {
await signInUserWithMicrosoftFB()
},
async signInWithEmailLink(_email, _url) {
const deviceIdentifier = getLocalConfig("deviceIdentifier")
const deviceIdentifier = persistenceService.getLocalConfig("deviceIdentifier")
if (!deviceIdentifier) {
throw new Error(
@@ -335,7 +335,7 @@ export const def: AuthPlatformDef = {
)
}
let verifyToken = getLocalConfig("verifyToken")
let verifyToken = persistenceService.getLocalConfig("verifyToken")
const client = await getClient();
let res = await client.post(`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, Body.json({
@@ -345,8 +345,8 @@ export const def: AuthPlatformDef = {
setAuthCookies(res.rawHeaders)
removeLocalConfig("deviceIdentifier")
removeLocalConfig("verifyToken")
persistenceService.removeLocalConfig("deviceIdentifier")
persistenceService.removeLocalConfig("verifyToken")
window.location.href = "/"
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -365,7 +365,7 @@ export const def: AuthPlatformDef = {
probableUser$.next(null)
currentUser$.next(null)
removeLocalConfig("login_state")
persistenceService.removeLocalConfig("login_state")
authEvents$.next({
event: "logout",

View File

@@ -1,21 +1,20 @@
import axios from "axios"
import { getService } from "@hoppscotch/common/modules/dioc"
import {
AuthEvent,
AuthPlatformDef,
HoppUser,
} from "@hoppscotch/common/platform/auth"
import { PersistenceService } from "@hoppscotch/common/services/persistence"
import axios from "axios"
import { BehaviorSubject, Subject } from "rxjs"
import {
getLocalConfig,
removeLocalConfig,
setLocalConfig,
} from "@hoppscotch/common/newstore/localpersistence"
import { Ref, ref, watch } from "vue"
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
const persistenceService = getService(PersistenceService)
async function logout() {
await axios.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`, {
withCredentials: true,
@@ -83,7 +82,7 @@ function setUser(user: HoppUser | null) {
currentUser$.next(user)
probableUser$.next(user)
setLocalConfig("login_state", JSON.stringify(user))
persistenceService.setLocalConfig("login_state", JSON.stringify(user))
}
async function setInitialUser() {
@@ -176,7 +175,10 @@ async function sendMagicLink(email: string) {
)
if (res.data && res.data.deviceIdentifier) {
setLocalConfig("deviceIdentifier", res.data.deviceIdentifier)
persistenceService.setLocalConfig(
"deviceIdentifier",
res.data.deviceIdentifier
)
} else {
throw new Error("test: does not get device identifier")
}
@@ -230,7 +232,9 @@ export const def: AuthPlatformDef = {
return null
},
async performAuthInit() {
const probableUser = JSON.parse(getLocalConfig("login_state") ?? "null")
const probableUser = JSON.parse(
persistenceService.getLocalConfig("login_state") ?? "null"
)
probableUser$.next(probableUser)
await setInitialUser()
},
@@ -281,7 +285,9 @@ export const def: AuthPlatformDef = {
const searchParams = new URLSearchParams(urlObject.search)
const token = searchParams.get("token")
const deviceIdentifier = getLocalConfig("deviceIdentifier")
const deviceIdentifier =
persistenceService.getLocalConfig("deviceIdentifier")
await axios.post(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`,
@@ -310,7 +316,8 @@ export const def: AuthPlatformDef = {
probableUser$.next(null)
currentUser$.next(null)
removeLocalConfig("login_state")
persistenceService.removeLocalConfig("login_state")
authEvents$.next({
event: "logout",
@@ -319,7 +326,8 @@ export const def: AuthPlatformDef = {
async processMagicLink() {
if (this.isSignInWithEmailLink(window.location.href)) {
const deviceIdentifier = getLocalConfig("deviceIdentifier")
const deviceIdentifier =
persistenceService.getLocalConfig("deviceIdentifier")
if (!deviceIdentifier) {
throw new Error(
@@ -329,7 +337,7 @@ export const def: AuthPlatformDef = {
await this.signInWithEmailLink(deviceIdentifier, window.location.href)
removeLocalConfig("deviceIdentifier")
persistenceService.removeLocalConfig("deviceIdentifier")
window.location.href = "/"
}
},