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

@@ -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()