feat: desktop app

Co-authored-by: Vivek R <123vivekr@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Andrew Bastin
2023-11-07 14:01:00 +05:30
parent 4ebf850cb6
commit 16044b5840
134 changed files with 11814 additions and 206 deletions

View File

@@ -0,0 +1,374 @@
import axios from "axios"
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'
import { listen } from '@tauri-apps/api/event'
import { Store } from "tauri-plugin-store-api";
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 APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
async function logout() {
let client = await getClient();
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
const store = new Store(APP_DATA_PATH)
await store.set("refresh_token", {})
await store.set("access_token", {})
await store.save()
}
async function signInUserWithGithubFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/github?redirect_uri=desktop`);
}
async function signInUserWithGoogleFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`);
}
async function signInUserWithMicrosoftFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/microsoft?redirect_uri=desktop`);
}
async function getInitialUserDetails() {
const store = new Store(APP_DATA_PATH);
try {
const accessToken = await store.get("access_token")
let client = await getClient()
let body = {
query: `query Me {
me {
uid
displayName
email
photoURL
isAdmin
createdOn
}
}`}
let res = await client.post(`${import.meta.env.VITE_BACKEND_GQL_URL}`,
Body.json(body), {
headers: {
"Cookie": `access_token=${accessToken.value}`,
}
}
)
return res.data
} catch (error) {
let res = {
error: "auth/cookies_not_found"
}
return res
}
}
const isGettingInitialUser: Ref<null | boolean> = ref(null)
function setUser(user: HoppUser | null) {
currentUser$.next(user)
probableUser$.next(user)
setLocalConfig("login_state", JSON.stringify(user))
}
async function setInitialUser() {
isGettingInitialUser.value = true
const res = await getInitialUserDetails()
const error = res.errors && res.errors[0]
// no cookies sent. so the user is not logged in
if (error && error.message === "auth/cookies_not_found") {
setUser(null)
isGettingInitialUser.value = false
return
}
if (error && error.message === "user/not_found") {
setUser(null)
isGettingInitialUser.value = false
return
}
// cookies sent, but it is expired, we need to refresh the token
if (error && error.message === "Unauthorized") {
const isRefreshSuccess = await refreshToken()
if (isRefreshSuccess) {
setInitialUser()
} else {
setUser(null)
isGettingInitialUser.value = false
}
return
}
// no errors, we have a valid user
if (res.data && res.data.me) {
const hoppBackendUser = res.data.me
const hoppUser: HoppUser = {
uid: hoppBackendUser.uid,
displayName: hoppBackendUser.displayName,
email: hoppBackendUser.email,
photoURL: hoppBackendUser.photoURL,
// all our signin methods currently guarantees the email is verified
emailVerified: true,
}
setUser(hoppUser)
isGettingInitialUser.value = false
authEvents$.next({
event: "login",
user: hoppUser,
})
return
}
}
async function refreshToken() {
const store = new Store(APP_DATA_PATH);
try {
const refreshToken = await store.get("refresh_token")
let client = await getClient()
let res = await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`, {
headers: { "Cookie": `refresh_token=${refreshToken.value}` }
})
setAuthCookies(res.rawHeaders)
const isSuccessful = res.status === 200
if (isSuccessful) {
authEvents$.next({
event: "token_refresh",
})
}
return isSuccessful
} catch (err) {
return false
}
}
async function sendMagicLink(email: string) {
const client = await getClient();
let url = `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=desktop`;
const res = await client.post(url, Body.json({ email }));
if (res.data && res.data.deviceIdentifier) {
setLocalConfig("deviceIdentifier", res.data.deviceIdentifier)
} else {
throw new Error("test: does not get device identifier")
}
return res.data
}
async function setAuthCookies(rawHeaders: Array<String>) {
let cookies = rawHeaders['set-cookie'].join("|")
const accessTokenMatch = cookies.match(/access_token=([^;]+)/);
const refreshTokenMatch = cookies.match(/refresh_token=([^;]+)/);
const store = new Store(APP_DATA_PATH)
if (accessTokenMatch) {
const accessToken = accessTokenMatch[1];
await store.set("access_token", { value: accessToken })
}
if (refreshTokenMatch) {
const refreshToken = refreshTokenMatch[1];
await store.set("refresh_token", { value: refreshToken })
}
await store.save()
}
export const def: AuthPlatformDef = {
getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$,
getProbableUserStream: () => probableUser$,
getCurrentUser: () => currentUser$.value,
getProbableUser: () => probableUser$.value,
getBackendHeaders() {
return {}
},
getGQLClientOptions() {
return {
fetchOptions: {
credentials: "include",
},
}
},
/**
* it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js
* hence just returning if the currentUser$ has a value associated with it
*/
willBackendHaveAuthError() {
return !currentUser$.value
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onBackendGQLClientShouldReconnect(func) {
authEvents$.subscribe((event) => {
if (
event.event == "login" ||
event.event == "logout" ||
event.event == "token_refresh"
) {
func()
}
})
},
/**
* we cannot access our auth cookies from javascript, so leaving this as null
*/
getDevOptsBackendIDToken() {
return null
},
async performAuthInit() {
const probableUser = JSON.parse(getLocalConfig("login_state") ?? "null")
probableUser$.next(probableUser)
await setInitialUser()
await listen('scheme-request-received', async (event: any) => {
let deep_link = event.payload as string;
const params = new URLSearchParams(deep_link.split('?')[1]);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const token = params.get('token');
function isNotNullOrUndefined(x: any) {
return x !== null && x !== undefined;
}
if (isNotNullOrUndefined(accessToken) && isNotNullOrUndefined(refreshToken)) {
const store = new Store(APP_DATA_PATH)
await store.set("access_token", { value: accessToken });
await store.set("refresh_token", { value: refreshToken } );
await store.save()
window.location.href = "/"
return;
}
if (isNotNullOrUndefined(token)) {
setLocalConfig("verifyToken", token)
await this.signInWithEmailLink("", "")
await setInitialUser()
}
});
},
waitProbableLoginToConfirm() {
return new Promise<void>((resolve, reject) => {
if (this.getCurrentUser()) {
resolve()
}
if (!probableUser$.value) reject(new Error("no_probable_user"))
const unwatch = watch(isGettingInitialUser, (val) => {
if (val === true || val === false) {
resolve()
unwatch()
}
})
})
},
async signInWithEmail(email: string) {
await sendMagicLink(email)
},
async verifyEmailAddress() {
return
},
async signInUserWithGoogle() {
await signInUserWithGoogleFB()
},
async signInUserWithGithub() {
await signInUserWithGithubFB()
return undefined
},
async signInUserWithMicrosoft() {
await signInUserWithMicrosoftFB()
},
async signInWithEmailLink(_email, _url) {
const deviceIdentifier = getLocalConfig("deviceIdentifier")
if (!deviceIdentifier) {
throw new Error(
"Device Identifier not found, you can only signin from the browser you generated the magic link"
)
}
let verifyToken = getLocalConfig("verifyToken")
const client = await getClient();
let res = await client.post(`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, Body.json({
token: verifyToken,
deviceIdentifier
}));
setAuthCookies(res.rawHeaders)
removeLocalConfig("deviceIdentifier")
removeLocalConfig("verifyToken")
window.location.href = "/"
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setEmailAddress(_email: string) {
return
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setDisplayName(name: string) {
return
},
async signOutUser() {
// if (!currentUser$.value) throw new Error("No user has logged in")
await logout()
probableUser$.next(null)
currentUser$.next(null)
removeLocalConfig("login_state")
authEvents$.next({
event: "logout",
})
},
}

View File

@@ -0,0 +1,305 @@
import {
runGQLQuery,
runGQLSubscription,
runMutation,
} from "@hoppscotch/common/helpers/backend/GQLClient"
import {
CreateRestRootUserCollectionDocument,
CreateRestRootUserCollectionMutation,
CreateRestRootUserCollectionMutationVariables,
CreateRestUserRequestMutation,
CreateRestUserRequestMutationVariables,
CreateRestUserRequestDocument,
CreateRestChildUserCollectionMutation,
CreateRestChildUserCollectionMutationVariables,
CreateRestChildUserCollectionDocument,
DeleteUserCollectionMutation,
DeleteUserCollectionMutationVariables,
DeleteUserCollectionDocument,
RenameUserCollectionMutation,
RenameUserCollectionMutationVariables,
RenameUserCollectionDocument,
MoveUserCollectionMutation,
MoveUserCollectionMutationVariables,
MoveUserCollectionDocument,
DeleteUserRequestMutation,
DeleteUserRequestMutationVariables,
DeleteUserRequestDocument,
MoveUserRequestDocument,
MoveUserRequestMutation,
MoveUserRequestMutationVariables,
UpdateUserCollectionOrderMutation,
UpdateUserCollectionOrderMutationVariables,
UpdateUserCollectionOrderDocument,
GetUserRootCollectionsQuery,
GetUserRootCollectionsQueryVariables,
GetUserRootCollectionsDocument,
UserCollectionCreatedDocument,
UserCollectionUpdatedDocument,
UserCollectionRemovedDocument,
UserCollectionMovedDocument,
UserCollectionOrderUpdatedDocument,
ExportUserCollectionsToJsonQuery,
ExportUserCollectionsToJsonQueryVariables,
ExportUserCollectionsToJsonDocument,
UserRequestCreatedDocument,
UserRequestUpdatedDocument,
UserRequestMovedDocument,
UserRequestDeletedDocument,
UpdateRestUserRequestMutation,
UpdateRestUserRequestMutationVariables,
UpdateRestUserRequestDocument,
CreateGqlRootUserCollectionMutation,
CreateGqlRootUserCollectionMutationVariables,
CreateGqlRootUserCollectionDocument,
CreateGqlUserRequestMutation,
CreateGqlUserRequestMutationVariables,
CreateGqlUserRequestDocument,
CreateGqlChildUserCollectionMutation,
CreateGqlChildUserCollectionMutationVariables,
CreateGqlChildUserCollectionDocument,
UpdateGqlUserRequestMutation,
UpdateGqlUserRequestMutationVariables,
UpdateGqlUserRequestDocument,
GetGqlRootUserCollectionsQuery,
GetGqlRootUserCollectionsQueryVariables,
GetGqlRootUserCollectionsDocument,
ReqType,
} from "../../api/generated/graphql"
export const createRESTRootUserCollection = (title: string) =>
runMutation<
CreateRestRootUserCollectionMutation,
CreateRestRootUserCollectionMutationVariables,
""
>(CreateRestRootUserCollectionDocument, {
title,
})()
export const createGQLRootUserCollection = (title: string) =>
runMutation<
CreateGqlRootUserCollectionMutation,
CreateGqlRootUserCollectionMutationVariables,
""
>(CreateGqlRootUserCollectionDocument, {
title,
})()
export const createRESTUserRequest = (
title: string,
request: string,
collectionID: string
) =>
runMutation<
CreateRestUserRequestMutation,
CreateRestUserRequestMutationVariables,
""
>(CreateRestUserRequestDocument, {
title,
request,
collectionID,
})()
export const createGQLUserRequest = (
title: string,
request: string,
collectionID: string
) =>
runMutation<
CreateGqlUserRequestMutation,
CreateGqlUserRequestMutationVariables,
""
>(CreateGqlUserRequestDocument, {
title,
request,
collectionID,
})()
export const createRESTChildUserCollection = (
title: string,
parentUserCollectionID: string
) =>
runMutation<
CreateRestChildUserCollectionMutation,
CreateRestChildUserCollectionMutationVariables,
""
>(CreateRestChildUserCollectionDocument, {
title,
parentUserCollectionID,
})()
export const createGQLChildUserCollection = (
title: string,
parentUserCollectionID: string
) =>
runMutation<
CreateGqlChildUserCollectionMutation,
CreateGqlChildUserCollectionMutationVariables,
""
>(CreateGqlChildUserCollectionDocument, {
title,
parentUserCollectionID,
})()
export const deleteUserCollection = (userCollectionID: string) =>
runMutation<
DeleteUserCollectionMutation,
DeleteUserCollectionMutationVariables,
""
>(DeleteUserCollectionDocument, {
userCollectionID,
})()
export const renameUserCollection = (
userCollectionID: string,
newTitle: string
) =>
runMutation<
RenameUserCollectionMutation,
RenameUserCollectionMutationVariables,
""
>(RenameUserCollectionDocument, { userCollectionID, newTitle })()
export const moveUserCollection = (
sourceCollectionID: string,
destinationCollectionID?: string
) =>
runMutation<
MoveUserCollectionMutation,
MoveUserCollectionMutationVariables,
""
>(MoveUserCollectionDocument, {
userCollectionID: sourceCollectionID,
destCollectionID: destinationCollectionID,
})()
export const editUserRequest = (
requestID: string,
title: string,
request: string
) =>
runMutation<
UpdateRestUserRequestMutation,
UpdateRestUserRequestMutationVariables,
""
>(UpdateRestUserRequestDocument, {
id: requestID,
request,
title,
})()
export const editGQLUserRequest = (
requestID: string,
title: string,
request: string
) =>
runMutation<
UpdateGqlUserRequestMutation,
UpdateGqlUserRequestMutationVariables,
""
>(UpdateGqlUserRequestDocument, {
id: requestID,
request,
title,
})()
export const deleteUserRequest = (requestID: string) =>
runMutation<
DeleteUserRequestMutation,
DeleteUserRequestMutationVariables,
""
>(DeleteUserRequestDocument, {
requestID,
})()
export const moveUserRequest = (
sourceCollectionID: string,
destinationCollectionID: string,
requestID: string,
nextRequestID?: string
) =>
runMutation<MoveUserRequestMutation, MoveUserRequestMutationVariables, "">(
MoveUserRequestDocument,
{
sourceCollectionID,
destinationCollectionID,
requestID,
nextRequestID,
}
)()
export const updateUserCollectionOrder = (
collectionID: string,
nextCollectionID?: string
) =>
runMutation<
UpdateUserCollectionOrderMutation,
UpdateUserCollectionOrderMutationVariables,
""
>(UpdateUserCollectionOrderDocument, {
collectionID,
nextCollectionID,
})()
export const getUserRootCollections = () =>
runGQLQuery<
GetUserRootCollectionsQuery,
GetUserRootCollectionsQueryVariables,
""
>({
query: GetUserRootCollectionsDocument,
variables: {},
})
export const getGQLRootUserCollections = () =>
runGQLQuery<
GetGqlRootUserCollectionsQuery,
GetGqlRootUserCollectionsQueryVariables,
""
>({
query: GetGqlRootUserCollectionsDocument,
variables: {},
})
export const exportUserCollectionsToJSON = (
collectionID?: string,
collectionType: ReqType.Rest | ReqType.Gql = ReqType.Rest
) =>
runGQLQuery<
ExportUserCollectionsToJsonQuery,
ExportUserCollectionsToJsonQueryVariables,
""
>({
query: ExportUserCollectionsToJsonDocument,
variables: { collectionID, collectionType },
})
export const runUserCollectionCreatedSubscription = () =>
runGQLSubscription({ query: UserCollectionCreatedDocument, variables: {} })
export const runUserCollectionUpdatedSubscription = () =>
runGQLSubscription({ query: UserCollectionUpdatedDocument, variables: {} })
export const runUserCollectionRemovedSubscription = () =>
runGQLSubscription({ query: UserCollectionRemovedDocument, variables: {} })
export const runUserCollectionMovedSubscription = () =>
runGQLSubscription({ query: UserCollectionMovedDocument, variables: {} })
export const runUserCollectionOrderUpdatedSubscription = () =>
runGQLSubscription({
query: UserCollectionOrderUpdatedDocument,
variables: {},
})
export const runUserRequestCreatedSubscription = () =>
runGQLSubscription({ query: UserRequestCreatedDocument, variables: {} })
export const runUserRequestUpdatedSubscription = () =>
runGQLSubscription({ query: UserRequestUpdatedDocument, variables: {} })
export const runUserRequestMovedSubscription = () =>
runGQLSubscription({ query: UserRequestMovedDocument, variables: {} })
export const runUserRequestDeletedSubscription = () =>
runGQLSubscription({ query: UserRequestDeletedDocument, variables: {} })

View File

@@ -0,0 +1,789 @@
import { authEvents$, def as platformAuth } from "@platform/auth"
import { CollectionsPlatformDef } from "@hoppscotch/common/platform/collections"
import { runDispatchWithOutSyncing } from "../../lib/sync"
import {
exportUserCollectionsToJSON,
runUserCollectionCreatedSubscription,
runUserCollectionMovedSubscription,
runUserCollectionOrderUpdatedSubscription,
runUserCollectionRemovedSubscription,
runUserCollectionUpdatedSubscription,
runUserRequestCreatedSubscription,
runUserRequestDeletedSubscription,
runUserRequestMovedSubscription,
runUserRequestUpdatedSubscription,
} from "./collections.api"
import { collectionsSyncer, getStoreByCollectionType } from "./collections.sync"
import * as E from "fp-ts/Either"
import {
addRESTCollection,
setRESTCollections,
editRESTCollection,
removeRESTCollection,
moveRESTFolder,
updateRESTCollectionOrder,
saveRESTRequestAs,
navigateToFolderWithIndexPath,
editRESTRequest,
removeRESTRequest,
moveRESTRequest,
updateRESTRequestOrder,
addRESTFolder,
editRESTFolder,
removeRESTFolder,
addGraphqlFolder,
addGraphqlCollection,
editGraphqlFolder,
editGraphqlCollection,
removeGraphqlFolder,
removeGraphqlCollection,
saveGraphqlRequestAs,
editGraphqlRequest,
moveGraphqlRequest,
removeGraphqlRequest,
setGraphqlCollections,
restCollectionStore,
} from "@hoppscotch/common/newstore/collections"
import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient"
import {
HoppCollection,
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { gqlCollectionsSyncer } from "./gqlCollections.sync"
import { ReqType } from "../../api/generated/graphql"
function initCollectionsSync() {
const currentUser$ = platformAuth.getCurrentUserStream()
collectionsSyncer.startStoreSync()
collectionsSyncer.setupSubscriptions(setupSubscriptions)
gqlCollectionsSyncer.startStoreSync()
loadUserCollections("REST")
loadUserCollections("GQL")
// TODO: test & make sure the auth thing is working properly
currentUser$.subscribe(async (user) => {
if (user) {
loadUserCollections("REST")
loadUserCollections("GQL")
}
})
authEvents$.subscribe((event) => {
if (event.event == "login" || event.event == "token_refresh") {
collectionsSyncer.startListeningToSubscriptions()
}
if (event.event == "logout") {
collectionsSyncer.stopListeningToSubscriptions()
}
})
}
type ExportedUserCollectionREST = {
id?: string
folders: ExportedUserCollectionREST[]
requests: Array<HoppRESTRequest & { id: string }>
name: string
}
type ExportedUserCollectionGQL = {
id?: string
folders: ExportedUserCollectionGQL[]
requests: Array<HoppGQLRequest & { id: string }>
name: string
}
function exportedCollectionToHoppCollection(
collection: ExportedUserCollectionREST | ExportedUserCollectionGQL,
collectionType: "REST" | "GQL"
): HoppCollection<HoppRESTRequest | HoppGQLRequest> {
if (collectionType == "REST") {
const restCollection = collection as ExportedUserCollectionREST
return {
id: restCollection.id,
v: 1,
name: restCollection.name,
folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
),
requests: restCollection.requests.map(
({
id,
v,
auth,
body,
endpoint,
headers,
method,
name,
params,
preRequestScript,
testScript,
}) => ({
id,
v,
auth,
body,
endpoint,
headers,
method,
name,
params,
preRequestScript,
testScript,
})
),
}
} else {
const gqlCollection = collection as ExportedUserCollectionGQL
return {
id: gqlCollection.id,
v: 1,
name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
),
requests: gqlCollection.requests.map(
({ v, auth, headers, name, id }) => ({
id,
v,
auth,
headers,
name,
})
) as HoppGQLRequest[],
}
}
}
async function loadUserCollections(collectionType: "REST" | "GQL") {
const res = await exportUserCollectionsToJSON(
undefined,
collectionType == "REST" ? ReqType.Rest : ReqType.Gql
)
if (E.isRight(res)) {
const collectionsJSONString =
res.right.exportUserCollectionsToJSON.exportedCollection
const exportedCollections = (
JSON.parse(collectionsJSONString) as Array<
ExportedUserCollectionGQL | ExportedUserCollectionREST
>
).map((collection) => ({ v: 1, ...collection }))
runDispatchWithOutSyncing(() => {
collectionType == "REST"
? setRESTCollections(
exportedCollections.map(
(collection) =>
exportedCollectionToHoppCollection(
collection,
"REST"
) as HoppCollection<HoppRESTRequest>
)
)
: setGraphqlCollections(
exportedCollections.map(
(collection) =>
exportedCollectionToHoppCollection(
collection,
"GQL"
) as HoppCollection<HoppGQLRequest>
)
)
})
}
}
function setupSubscriptions() {
let subs: ReturnType<typeof runGQLSubscription>[1][] = []
const userCollectionCreatedSub = setupUserCollectionCreatedSubscription()
const userCollectionUpdatedSub = setupUserCollectionUpdatedSubscription()
const userCollectionRemovedSub = setupUserCollectionRemovedSubscription()
const userCollectionMovedSub = setupUserCollectionMovedSubscription()
const userCollectionOrderUpdatedSub =
setupUserCollectionOrderUpdatedSubscription()
const userRequestCreatedSub = setupUserRequestCreatedSubscription()
const userRequestUpdatedSub = setupUserRequestUpdatedSubscription()
const userRequestDeletedSub = setupUserRequestDeletedSubscription()
const userRequestMovedSub = setupUserRequestMovedSubscription()
subs = [
userCollectionCreatedSub,
userCollectionUpdatedSub,
userCollectionRemovedSub,
userCollectionMovedSub,
userCollectionOrderUpdatedSub,
userRequestCreatedSub,
userRequestUpdatedSub,
userRequestDeletedSub,
userRequestMovedSub,
]
return () => {
subs.forEach((sub) => sub.unsubscribe())
}
}
function setupUserCollectionCreatedSubscription() {
const [userCollectionCreated$, userCollectionCreatedSub] =
runUserCollectionCreatedSubscription()
userCollectionCreated$.subscribe((res) => {
if (E.isRight(res)) {
const collectionType = res.right.userCollectionCreated.type
const { collectionStore } = getStoreByCollectionType(collectionType)
const userCollectionBackendID = res.right.userCollectionCreated.id
const parentCollectionID = res.right.userCollectionCreated.parent?.id
const userCollectionLocalID = getCollectionPathFromCollectionID(
userCollectionBackendID,
collectionStore.value.state
)
// collection already exists in store ( this instance created it )
if (userCollectionLocalID) {
return
}
const parentCollectionPath =
parentCollectionID &&
getCollectionPathFromCollectionID(
parentCollectionID,
collectionStore.value.state
)
// only folders will have parent collection id
if (parentCollectionID && parentCollectionPath) {
runDispatchWithOutSyncing(() => {
collectionType == "GQL"
? addGraphqlFolder(
res.right.userCollectionCreated.title,
parentCollectionPath
)
: addRESTFolder(
res.right.userCollectionCreated.title,
parentCollectionPath
)
const parentCollection = navigateToFolderWithIndexPath(
collectionStore.value.state,
parentCollectionPath
.split("/")
.map((pathIndex) => parseInt(pathIndex))
)
if (parentCollection) {
const folderIndex = parentCollection.folders.length - 1
const addedFolder = parentCollection.folders[folderIndex]
addedFolder.id = userCollectionBackendID
}
})
} else {
// root collections won't have parentCollectionID
runDispatchWithOutSyncing(() => {
collectionType == "GQL"
? addGraphqlCollection({
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 1,
})
: addRESTCollection({
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 1,
})
const localIndex = collectionStore.value.state.length - 1
const addedCollection = collectionStore.value.state[localIndex]
addedCollection.id = userCollectionBackendID
})
}
}
})
return userCollectionCreatedSub
}
function setupUserCollectionUpdatedSubscription() {
const [userCollectionUpdated$, userCollectionUpdatedSub] =
runUserCollectionUpdatedSubscription()
userCollectionUpdated$.subscribe((res) => {
if (E.isRight(res)) {
const collectionType = res.right.userCollectionUpdated.type
const { collectionStore } = getStoreByCollectionType(collectionType)
const updatedCollectionBackendID = res.right.userCollectionUpdated.id
const updatedCollectionLocalPath = getCollectionPathFromCollectionID(
updatedCollectionBackendID,
collectionStore.value.state
)
const isFolder =
updatedCollectionLocalPath &&
updatedCollectionLocalPath.split("/").length > 1
// updated collection is a folder
if (isFolder) {
runDispatchWithOutSyncing(() => {
collectionType == "REST"
? editRESTFolder(updatedCollectionLocalPath, {
name: res.right.userCollectionUpdated.title,
})
: editGraphqlFolder(updatedCollectionLocalPath, {
name: res.right.userCollectionUpdated.title,
})
})
}
// updated collection is a root collection
if (updatedCollectionLocalPath && !isFolder) {
runDispatchWithOutSyncing(() => {
collectionType == "REST"
? editRESTCollection(parseInt(updatedCollectionLocalPath), {
name: res.right.userCollectionUpdated.title,
})
: editGraphqlCollection(parseInt(updatedCollectionLocalPath), {
name: res.right.userCollectionUpdated.title,
})
})
}
}
})
return userCollectionUpdatedSub
}
function setupUserCollectionMovedSubscription() {
const [userCollectionMoved$, userCollectionMovedSub] =
runUserCollectionMovedSubscription()
userCollectionMoved$.subscribe((res) => {
if (E.isRight(res)) {
const movedMetadata = res.right.userCollectionMoved
const sourcePath = getCollectionPathFromCollectionID(
movedMetadata.id,
restCollectionStore.value.state
)
let destinationPath: string | undefined
if (movedMetadata.parent?.id) {
destinationPath =
getCollectionPathFromCollectionID(
movedMetadata.parent?.id,
restCollectionStore.value.state
) ?? undefined
}
sourcePath &&
runDispatchWithOutSyncing(() => {
moveRESTFolder(sourcePath, destinationPath ?? null)
})
}
})
return userCollectionMovedSub
}
function setupUserCollectionRemovedSubscription() {
const [userCollectionRemoved$, userCollectionRemovedSub] =
runUserCollectionRemovedSubscription()
userCollectionRemoved$.subscribe((res) => {
if (E.isRight(res)) {
const removedCollectionBackendID = res.right.userCollectionRemoved.id
const collectionType = res.right.userCollectionRemoved.type
const { collectionStore } = getStoreByCollectionType(collectionType)
const removedCollectionLocalPath = getCollectionPathFromCollectionID(
removedCollectionBackendID,
collectionStore.value.state
)
const isFolder =
removedCollectionLocalPath &&
removedCollectionLocalPath.split("/").length > 1
if (removedCollectionLocalPath && isFolder) {
runDispatchWithOutSyncing(() => {
collectionType == "REST"
? removeRESTFolder(removedCollectionLocalPath)
: removeGraphqlFolder(removedCollectionLocalPath)
})
}
if (removedCollectionLocalPath && !isFolder) {
runDispatchWithOutSyncing(() => {
collectionType == "REST"
? removeRESTCollection(parseInt(removedCollectionLocalPath))
: removeGraphqlCollection(parseInt(removedCollectionLocalPath))
})
}
}
})
return userCollectionRemovedSub
}
function setupUserCollectionOrderUpdatedSubscription() {
const [userCollectionOrderUpdated$, userCollectionOrderUpdatedSub] =
runUserCollectionOrderUpdatedSubscription()
userCollectionOrderUpdated$.subscribe((res) => {
if (E.isRight(res)) {
const { userCollection, nextUserCollection } =
res.right.userCollectionOrderUpdated
const sourceCollectionID = userCollection.id
const destinationCollectionID = nextUserCollection?.id
const sourcePath = getCollectionPathFromCollectionID(
sourceCollectionID,
restCollectionStore.value.state
)
let destinationPath: string | null | undefined
if (destinationCollectionID) {
destinationPath = getCollectionPathFromCollectionID(
destinationCollectionID,
restCollectionStore.value.state
)
}
runDispatchWithOutSyncing(() => {
if (sourcePath) {
updateRESTCollectionOrder(sourcePath, destinationPath ?? null)
}
})
}
})
return userCollectionOrderUpdatedSub
}
function setupUserRequestCreatedSubscription() {
const [userRequestCreated$, userRequestCreatedSub] =
runUserRequestCreatedSubscription()
userRequestCreated$.subscribe((res) => {
if (E.isRight(res)) {
const collectionID = res.right.userRequestCreated.collectionID
const request = JSON.parse(res.right.userRequestCreated.request)
const requestID = res.right.userRequestCreated.id
const requestType = res.right.userRequestCreated.type
const { collectionStore } = getStoreByCollectionType(requestType)
const hasAlreadyHappened = getRequestPathFromRequestID(
requestID,
collectionStore.value.state
)
if (!!hasAlreadyHappened) {
return
}
const collectionPath = getCollectionPathFromCollectionID(
collectionID,
collectionStore.value.state
)
if (collectionID && collectionPath) {
runDispatchWithOutSyncing(() => {
requestType == "REST"
? saveRESTRequestAs(collectionPath, request)
: saveGraphqlRequestAs(collectionPath, request)
const target = navigateToFolderWithIndexPath(
collectionStore.value.state,
collectionPath.split("/").map((index) => parseInt(index))
)
const targetRequest = target?.requests[target?.requests.length - 1]
if (targetRequest) {
targetRequest.id = requestID
}
})
}
}
})
return userRequestCreatedSub
}
function setupUserRequestUpdatedSubscription() {
const [userRequestUpdated$, userRequestUpdatedSub] =
runUserRequestUpdatedSubscription()
userRequestUpdated$.subscribe((res) => {
if (E.isRight(res)) {
const requestType = res.right.userRequestUpdated.type
const { collectionStore } = getStoreByCollectionType(requestType)
const requestPath = getRequestPathFromRequestID(
res.right.userRequestUpdated.id,
collectionStore.value.state
)
const collectionPath = requestPath?.collectionPath
const requestIndex = requestPath?.requestIndex
;(requestIndex || requestIndex == 0) &&
collectionPath &&
runDispatchWithOutSyncing(() => {
requestType == "REST"
? editRESTRequest(
collectionPath,
requestIndex,
JSON.parse(res.right.userRequestUpdated.request)
)
: editGraphqlRequest(
collectionPath,
requestIndex,
JSON.parse(res.right.userRequestUpdated.request)
)
})
}
})
return userRequestUpdatedSub
}
function setupUserRequestMovedSubscription() {
const [userRequestMoved$, userRequestMovedSub] =
runUserRequestMovedSubscription()
userRequestMoved$.subscribe((res) => {
if (E.isRight(res)) {
const { request, nextRequest } = res.right.userRequestMoved
const {
collectionID: destinationCollectionID,
id: sourceRequestID,
type: requestType,
} = request
const { collectionStore } = getStoreByCollectionType(requestType)
const sourceRequestPath = getRequestPathFromRequestID(
sourceRequestID,
collectionStore.value.state
)
const destinationCollectionPath = getCollectionPathFromCollectionID(
destinationCollectionID,
collectionStore.value.state
)
const destinationRequestIndex = destinationCollectionPath
? (() => {
const requestsLength = navigateToFolderWithIndexPath(
collectionStore.value.state,
destinationCollectionPath
.split("/")
.map((index) => parseInt(index))
)?.requests.length
return requestsLength || requestsLength == 0
? requestsLength - 1
: undefined
})()
: undefined
// there is no nextRequest, so request is moved
if (
(destinationRequestIndex || destinationRequestIndex == 0) &&
destinationCollectionPath &&
sourceRequestPath &&
!nextRequest
) {
runDispatchWithOutSyncing(() => {
requestType == "REST"
? moveRESTRequest(
sourceRequestPath.collectionPath,
sourceRequestPath.requestIndex,
destinationCollectionPath
)
: moveGraphqlRequest(
sourceRequestPath.collectionPath,
sourceRequestPath.requestIndex,
destinationCollectionPath
)
})
}
// there is nextRequest, so request is reordered
if (
(destinationRequestIndex || destinationRequestIndex == 0) &&
destinationCollectionPath &&
nextRequest &&
// we don't have request reordering for graphql yet
requestType == "REST"
) {
const { collectionID: nextCollectionID, id: nextRequestID } =
nextRequest
const nextCollectionPath =
getCollectionPathFromCollectionID(
nextCollectionID,
collectionStore.value.state
) ?? undefined
const nextRequestIndex = nextCollectionPath
? getRequestIndex(
nextRequestID,
nextCollectionPath,
collectionStore.value.state
)
: undefined
nextRequestIndex &&
nextCollectionPath &&
sourceRequestPath &&
runDispatchWithOutSyncing(() => {
updateRESTRequestOrder(
sourceRequestPath?.requestIndex,
nextRequestIndex,
nextCollectionPath
)
})
}
}
})
return userRequestMovedSub
}
function setupUserRequestDeletedSubscription() {
const [userRequestDeleted$, userRequestDeletedSub] =
runUserRequestDeletedSubscription()
userRequestDeleted$.subscribe((res) => {
if (E.isRight(res)) {
const requestType = res.right.userRequestDeleted.type
const { collectionStore } = getStoreByCollectionType(requestType)
const deletedRequestPath = getRequestPathFromRequestID(
res.right.userRequestDeleted.id,
collectionStore.value.state
)
;(deletedRequestPath?.requestIndex ||
deletedRequestPath?.requestIndex == 0) &&
deletedRequestPath.collectionPath &&
runDispatchWithOutSyncing(() => {
requestType == "REST"
? removeRESTRequest(
deletedRequestPath.collectionPath,
deletedRequestPath.requestIndex
)
: removeGraphqlRequest(
deletedRequestPath.collectionPath,
deletedRequestPath.requestIndex
)
})
}
})
return userRequestDeletedSub
}
export const def: CollectionsPlatformDef = {
initCollectionsSync,
}
function getCollectionPathFromCollectionID(
collectionID: string,
collections: HoppCollection<HoppRESTRequest | HoppGQLRequest>[],
parentPath?: string
): string | null {
for (const collectionIndex in collections) {
if (collections[collectionIndex].id == collectionID) {
return parentPath
? `${parentPath}/${collectionIndex}`
: `${collectionIndex}`
} else {
const collectionPath = getCollectionPathFromCollectionID(
collectionID,
collections[collectionIndex].folders,
parentPath ? `${parentPath}/${collectionIndex}` : `${collectionIndex}`
)
if (collectionPath) return collectionPath
}
}
return null
}
function getRequestPathFromRequestID(
requestID: string,
collections: HoppCollection<HoppRESTRequest | HoppGQLRequest>[],
parentPath?: string
): { collectionPath: string; requestIndex: number } | null {
for (const collectionIndex in collections) {
const requestIndex = collections[collectionIndex].requests.findIndex(
(request) => request.id == requestID
)
if (requestIndex != -1) {
return {
collectionPath: parentPath
? `${parentPath}/${collectionIndex}`
: `${collectionIndex}`,
requestIndex,
}
} else {
const requestPath = getRequestPathFromRequestID(
requestID,
collections[collectionIndex].folders,
parentPath ? `${parentPath}/${collectionIndex}` : `${collectionIndex}`
)
if (requestPath) return requestPath
}
}
return null
}
function getRequestIndex(
requestID: string,
parentCollectionPath: string,
collections: HoppCollection<HoppRESTRequest | HoppGQLRequest>[]
) {
const collection = navigateToFolderWithIndexPath(
collections,
parentCollectionPath?.split("/").map((index) => parseInt(index))
)
const requestIndex = collection?.requests.findIndex(
(request) => request.id == requestID
)
return requestIndex
}

View File

@@ -0,0 +1,543 @@
import {
graphqlCollectionStore,
navigateToFolderWithIndexPath,
removeDuplicateRESTCollectionOrFolder,
restCollectionStore,
} from "@hoppscotch/common/newstore/collections"
import {
getSettingSubject,
settingsStore,
} from "@hoppscotch/common/newstore/settings"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { getSyncInitFunction } from "../../lib/sync"
import { StoreSyncDefinitionOf } from "../../lib/sync"
import { createMapper } from "../../lib/sync/mapper"
import {
createRESTChildUserCollection,
createRESTRootUserCollection,
createRESTUserRequest,
deleteUserCollection,
deleteUserRequest,
editUserRequest,
moveUserCollection,
moveUserRequest,
renameUserCollection,
updateUserCollectionOrder,
} from "./collections.api"
import * as E from "fp-ts/Either"
// restCollectionsMapper uses the collectionPath as the local identifier
export const restCollectionsMapper = createMapper<string, string>()
// restRequestsMapper uses the collectionPath/requestIndex as the local identifier
export const restRequestsMapper = createMapper<string, string>()
// temp implementation untill the backend implements an endpoint that accepts an entire collection
// TODO: use importCollectionsJSON to do this
const recursivelySyncCollections = async (
collection: HoppCollection<HoppRESTRequest>,
collectionPath: string,
parentUserCollectionID?: string
) => {
let parentCollectionID = parentUserCollectionID
// if parentUserCollectionID does not exist, create the collection as a root collection
if (!parentUserCollectionID) {
const res = await createRESTRootUserCollection(collection.name)
if (E.isRight(res)) {
parentCollectionID = res.right.createRESTRootUserCollection.id
collection.id = parentCollectionID
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
} else {
parentCollectionID = undefined
}
} else {
// if parentUserCollectionID exists, create the collection as a child collection
const res = await createRESTChildUserCollection(
collection.name,
parentUserCollectionID
)
if (E.isRight(res)) {
const childCollectionId = res.right.createRESTChildUserCollection.id
collection.id = childCollectionId
removeDuplicateRESTCollectionOrFolder(
childCollectionId,
`${collectionPath}`
)
}
}
// create the requests
if (parentCollectionID) {
collection.requests.forEach(async (request) => {
const res =
parentCollectionID &&
(await createRESTUserRequest(
request.name,
JSON.stringify(request),
parentCollectionID
))
if (res && E.isRight(res)) {
const requestId = res.right.createRESTUserRequest.id
request.id = requestId
}
})
}
// create the folders aka child collections
if (parentCollectionID)
collection.folders.forEach(async (folder, index) => {
recursivelySyncCollections(
folder,
`${collectionPath}/${index}`,
parentCollectionID
)
})
}
// TODO: generalize this
// TODO: ask backend to send enough info on the subscription to not need this
export const collectionReorderOrMovingOperations: {
sourceCollectionID: string
destinationCollectionID?: string
reorderOperation: {
fromPath: string
toPath?: string
}
}[] = []
type OperationStatus = "pending" | "completed"
type OperationCollectionRemoved = {
type: "COLLECTION_REMOVED"
collectionBackendID: string
status: OperationStatus
}
export const restCollectionsOperations: Array<OperationCollectionRemoved> = []
export const storeSyncDefinition: StoreSyncDefinitionOf<
typeof restCollectionStore
> = {
appendCollections({ entries }) {
let indexStart = restCollectionStore.value.state.length - entries.length
entries.forEach((collection) => {
recursivelySyncCollections(collection, `${indexStart}`)
indexStart++
})
},
async addCollection({ collection }) {
const lastCreatedCollectionIndex =
restCollectionStore.value.state.length - 1
recursivelySyncCollections(collection, `${lastCreatedCollectionIndex}`)
},
async removeCollection({ collectionID }) {
if (collectionID) {
await deleteUserCollection(collectionID)
}
},
editCollection({ partialCollection: collection, collectionIndex }) {
const collectionID = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
[collectionIndex]
)?.id
if (collectionID && collection.name) {
renameUserCollection(collectionID, collection.name)
}
},
async addFolder({ name, path }) {
const parentCollection = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)
const parentCollectionBackendID = parentCollection?.id
if (parentCollectionBackendID) {
const foldersLength = parentCollection.folders.length
const res = await createRESTChildUserCollection(
name,
parentCollectionBackendID
)
if (E.isRight(res)) {
const { id } = res.right.createRESTChildUserCollection
if (foldersLength) {
parentCollection.folders[foldersLength - 1].id = id
removeDuplicateRESTCollectionOrFolder(
id,
`${path}/${foldersLength - 1}`
)
}
}
}
},
editFolder({ folder, path }) {
const folderID = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.id
const folderName = folder.name
if (folderID && folderName) {
renameUserCollection(folderID, folderName)
}
},
async removeFolder({ folderID }) {
if (folderID) {
await deleteUserCollection(folderID)
}
},
async moveFolder({ destinationPath, path }) {
const { newSourcePath, newDestinationPath } = getPathsAfterMoving(
path,
destinationPath ?? undefined
)
if (newSourcePath) {
const sourceCollectionID = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
newSourcePath.split("/").map((index) => parseInt(index))
)?.id
const destinationCollectionID = destinationPath
? newDestinationPath &&
navigateToFolderWithIndexPath(
restCollectionStore.value.state,
newDestinationPath.split("/").map((index) => parseInt(index))
)?.id
: undefined
if (sourceCollectionID) {
await moveUserCollection(sourceCollectionID, destinationCollectionID)
}
}
},
editRequest({ path, requestIndex, requestNew }) {
const request = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.requests[requestIndex]
const requestBackendID = request?.id
if (requestBackendID) {
editUserRequest(
requestBackendID,
(requestNew as HoppRESTRequest).name,
JSON.stringify(requestNew)
)
}
},
async saveRequestAs({ path, request }) {
const folder = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)
const parentCollectionBackendID = folder?.id
if (parentCollectionBackendID) {
const newRequest = folder.requests[folder.requests.length - 1]
const res = await createRESTUserRequest(
(request as HoppRESTRequest).name,
JSON.stringify(request),
parentCollectionBackendID
)
if (E.isRight(res)) {
const { id } = res.right.createRESTUserRequest
newRequest.id = id
removeDuplicateRESTCollectionOrFolder(
id,
`${path}/${folder.requests.length - 1}`,
"request"
)
}
}
},
async removeRequest({ requestID }) {
if (requestID) {
await deleteUserRequest(requestID)
}
},
moveRequest({ destinationPath, path, requestIndex }) {
moveOrReorderRequests(requestIndex, path, destinationPath)
},
updateRequestOrder({
destinationCollectionPath,
destinationRequestIndex,
requestIndex,
}) {
/**
* currently the FE implementation only supports reordering requests between the same collection,
* so destinationCollectionPath and sourceCollectionPath will be same
*/
moveOrReorderRequests(
requestIndex,
destinationCollectionPath,
destinationCollectionPath,
destinationRequestIndex ?? undefined
)
},
async updateCollectionOrder({
collectionIndex: collectionPath,
destinationCollectionIndex: destinationCollectionPath,
}) {
const collections = restCollectionStore.value.state
const sourcePathIndexes = getParentPathIndexesFromPath(collectionPath)
const sourceCollectionIndex = getCollectionIndexFromPath(collectionPath)
const destinationCollectionIndex = !!destinationCollectionPath
? getCollectionIndexFromPath(destinationCollectionPath)
: undefined
let updatedCollectionIndexs:
| [newSourceIndex: number, newDestinationIndex: number | undefined]
| undefined
if (
(sourceCollectionIndex || sourceCollectionIndex == 0) &&
(destinationCollectionIndex || destinationCollectionIndex == 0)
) {
updatedCollectionIndexs = getIndexesAfterReorder(
sourceCollectionIndex,
destinationCollectionIndex
)
} else if (sourceCollectionIndex || sourceCollectionIndex == 0) {
if (sourcePathIndexes.length == 0) {
// we're reordering root collections
updatedCollectionIndexs = [collections.length - 1, undefined]
} else {
const sourceCollection = navigateToFolderWithIndexPath(collections, [
...sourcePathIndexes,
])
if (sourceCollection && sourceCollection.folders.length > 0) {
updatedCollectionIndexs = [
sourceCollection.folders.length - 1,
undefined,
]
}
}
}
const sourceCollectionID =
updatedCollectionIndexs &&
navigateToFolderWithIndexPath(collections, [
...sourcePathIndexes,
updatedCollectionIndexs[0],
])?.id
const destinationCollectionID =
updatedCollectionIndexs &&
(updatedCollectionIndexs[1] || updatedCollectionIndexs[1] == 0)
? navigateToFolderWithIndexPath(collections, [
...sourcePathIndexes,
updatedCollectionIndexs[1],
])?.id
: undefined
if (sourceCollectionID) {
await updateUserCollectionOrder(
sourceCollectionID,
destinationCollectionID
)
}
},
}
export const collectionsSyncer = getSyncInitFunction(
restCollectionStore,
storeSyncDefinition,
() => settingsStore.value.syncCollections,
getSettingSubject("syncCollections")
)
export async function moveOrReorderRequests(
requestIndex: number,
path: string,
destinationPath: string,
nextRequestIndex?: number,
requestType: "REST" | "GQL" = "REST"
) {
const { collectionStore } = getStoreByCollectionType(requestType)
const sourceCollectionBackendID = navigateToFolderWithIndexPath(
collectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.id
const destinationCollection = navigateToFolderWithIndexPath(
collectionStore.value.state,
destinationPath.split("/").map((index) => parseInt(index))
)
const destinationCollectionBackendID = destinationCollection?.id
let requestBackendID: string | undefined
let nextRequestBackendID: string | undefined
// we only need this for reordering requests, not for moving requests
if (nextRequestIndex) {
// reordering
const [newRequestIndex, newDestinationIndex] = getIndexesAfterReorder(
requestIndex,
nextRequestIndex
)
requestBackendID =
destinationCollection?.requests[newRequestIndex]?.id ?? undefined
nextRequestBackendID =
destinationCollection?.requests[newDestinationIndex]?.id ?? undefined
} else {
// moving
const requests = destinationCollection?.requests
requestBackendID =
requests && requests.length > 0
? requests[requests.length - 1]?.id
: undefined
}
if (
sourceCollectionBackendID &&
destinationCollectionBackendID &&
requestBackendID
) {
await moveUserRequest(
sourceCollectionBackendID,
destinationCollectionBackendID,
requestBackendID,
nextRequestBackendID
)
}
}
function getParentPathIndexesFromPath(path: string) {
const indexes = path.split("/")
indexes.pop()
return indexes.map((index) => parseInt(index))
}
export function getCollectionIndexFromPath(collectionPath: string) {
const sourceCollectionIndexString = collectionPath.split("/").pop()
const sourceCollectionIndex = sourceCollectionIndexString
? parseInt(sourceCollectionIndexString)
: undefined
return sourceCollectionIndex
}
/**
* the sync function is called after the reordering has happened on the store
* because of this we need to find the new source and destination indexes after the reordering
*/
function getIndexesAfterReorder(
oldSourceIndex: number,
oldDestinationIndex: number
): [newSourceIndex: number, newDestinationIndex: number] {
// Source Becomes Destination -1
// Destination Remains Same
if (oldSourceIndex < oldDestinationIndex) {
return [oldDestinationIndex - 1, oldDestinationIndex]
}
// Source Becomes The Destination
// Destintion Becomes Source + 1
if (oldSourceIndex > oldDestinationIndex) {
return [oldDestinationIndex, oldDestinationIndex + 1]
}
throw new Error("Source and Destination are the same")
}
/**
* the sync function is called after moving a folder has happened on the store,
* because of this the source index given to the sync function is not the live one
* we need to find the new source index after the moving
*/
function getPathsAfterMoving(sourcePath: string, destinationPath?: string) {
if (!destinationPath) {
return {
newSourcePath: `${restCollectionStore.value.state.length - 1}`,
newDestinationPath: destinationPath,
}
}
const sourceParentPath = getParentPathFromPath(sourcePath)
const destinationParentPath = getParentPathFromPath(destinationPath)
const isSameParentPath = sourceParentPath === destinationParentPath
let newDestinationPath: string
if (isSameParentPath) {
const sourceIndex = getCollectionIndexFromPath(sourcePath)
const destinationIndex = getCollectionIndexFromPath(destinationPath)
if (
(sourceIndex || sourceIndex == 0) &&
(destinationIndex || destinationIndex == 0) &&
sourceIndex < destinationIndex
) {
newDestinationPath = destinationParentPath
? `${destinationParentPath}/${destinationIndex - 1}`
: `${destinationIndex - 1}`
} else {
newDestinationPath = destinationPath
}
} else {
newDestinationPath = destinationPath
}
const destinationFolder = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
newDestinationPath.split("/").map((index) => parseInt(index))
)
const newSourcePath = destinationFolder
? `${newDestinationPath}/${destinationFolder?.folders.length - 1}`
: undefined
return {
newSourcePath,
newDestinationPath,
}
}
function getParentPathFromPath(path: string | undefined) {
const indexes = path ? path.split("/") : []
indexes.pop()
return indexes.join("/")
}
export function getStoreByCollectionType(type: "GQL" | "REST") {
const isGQL = type == "GQL"
const collectionStore = isGQL ? graphqlCollectionStore : restCollectionStore
return { collectionStore }
}

View File

@@ -0,0 +1,269 @@
import {
graphqlCollectionStore,
navigateToFolderWithIndexPath,
removeDuplicateGraphqlCollectionOrFolder,
} from "@hoppscotch/common/newstore/collections"
import {
getSettingSubject,
settingsStore,
} from "@hoppscotch/common/newstore/settings"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { getSyncInitFunction } from "../../lib/sync"
import { StoreSyncDefinitionOf } from "../../lib/sync"
import { createMapper } from "../../lib/sync/mapper"
import {
createGQLChildUserCollection,
createGQLRootUserCollection,
createGQLUserRequest,
deleteUserCollection,
deleteUserRequest,
editGQLUserRequest,
renameUserCollection,
} from "./collections.api"
import * as E from "fp-ts/Either"
import { moveOrReorderRequests } from "./collections.sync"
// gqlCollectionsMapper uses the collectionPath as the local identifier
export const gqlCollectionsMapper = createMapper<string, string>()
// gqlRequestsMapper uses the collectionPath/requestIndex as the local identifier
export const gqlRequestsMapper = createMapper<string, string>()
// temp implementation untill the backend implements an endpoint that accepts an entire collection
// TODO: use importCollectionsJSON to do this
const recursivelySyncCollections = async (
collection: HoppCollection<HoppRESTRequest>,
collectionPath: string,
parentUserCollectionID?: string
) => {
let parentCollectionID = parentUserCollectionID
// if parentUserCollectionID does not exist, create the collection as a root collection
if (!parentUserCollectionID) {
const res = await createGQLRootUserCollection(collection.name)
if (E.isRight(res)) {
parentCollectionID = res.right.createGQLRootUserCollection.id
collection.id = parentCollectionID
removeDuplicateGraphqlCollectionOrFolder(
parentCollectionID,
collectionPath
)
} else {
parentCollectionID = undefined
}
} else {
// if parentUserCollectionID exists, create the collection as a child collection
const res = await createGQLChildUserCollection(
collection.name,
parentUserCollectionID
)
if (E.isRight(res)) {
const childCollectionId = res.right.createGQLChildUserCollection.id
collection.id = childCollectionId
removeDuplicateGraphqlCollectionOrFolder(
childCollectionId,
`${collectionPath}`
)
}
}
// create the requests
if (parentCollectionID) {
collection.requests.forEach(async (request) => {
const res =
parentCollectionID &&
(await createGQLUserRequest(
request.name,
JSON.stringify(request),
parentCollectionID
))
if (res && E.isRight(res)) {
const requestId = res.right.createGQLUserRequest.id
request.id = requestId
}
})
}
// create the folders aka child collections
if (parentCollectionID)
collection.folders.forEach(async (folder, index) => {
recursivelySyncCollections(
folder,
`${collectionPath}/${index}`,
parentCollectionID
)
})
}
// TODO: generalize this
// TODO: ask backend to send enough info on the subscription to not need this
export const collectionReorderOrMovingOperations: {
sourceCollectionID: string
destinationCollectionID?: string
reorderOperation: {
fromPath: string
toPath?: string
}
}[] = []
type OperationStatus = "pending" | "completed"
type OperationCollectionRemoved = {
type: "COLLECTION_REMOVED"
collectionBackendID: string
status: OperationStatus
}
export const gqlCollectionsOperations: Array<OperationCollectionRemoved> = []
export const storeSyncDefinition: StoreSyncDefinitionOf<
typeof graphqlCollectionStore
> = {
appendCollections({ entries }) {
let indexStart = graphqlCollectionStore.value.state.length - entries.length
entries.forEach((collection) => {
recursivelySyncCollections(collection, `${indexStart}`)
indexStart++
})
},
async addCollection({ collection }) {
const lastCreatedCollectionIndex =
graphqlCollectionStore.value.state.length - 1
await recursivelySyncCollections(
collection,
`${lastCreatedCollectionIndex}`
)
},
async removeCollection({ collectionID }) {
if (collectionID) {
await deleteUserCollection(collectionID)
}
},
editCollection({ collection, collectionIndex }) {
const collectionID = navigateToFolderWithIndexPath(
graphqlCollectionStore.value.state,
[collectionIndex]
)?.id
if (collectionID && collection.name) {
renameUserCollection(collectionID, collection.name)
}
},
async addFolder({ name, path }) {
const parentCollection = navigateToFolderWithIndexPath(
graphqlCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)
const parentCollectionBackendID = parentCollection?.id
if (parentCollectionBackendID) {
const foldersLength = parentCollection.folders.length
const res = await createGQLChildUserCollection(
name,
parentCollectionBackendID
)
if (E.isRight(res)) {
const { id } = res.right.createGQLChildUserCollection
if (foldersLength) {
parentCollection.folders[foldersLength - 1].id = id
removeDuplicateGraphqlCollectionOrFolder(
id,
`${path}/${foldersLength - 1}`
)
}
}
}
},
editFolder({ folder, path }) {
const folderBackendId = navigateToFolderWithIndexPath(
graphqlCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.id
if (folderBackendId && folder.name) {
renameUserCollection(folderBackendId, folder.name)
}
},
async removeFolder({ folderID }) {
if (folderID) {
await deleteUserCollection(folderID)
}
},
editRequest({ path, requestIndex, requestNew }) {
const request = navigateToFolderWithIndexPath(
graphqlCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.requests[requestIndex]
const requestBackendID = request?.id
if (requestBackendID) {
editGQLUserRequest(
requestBackendID,
(requestNew as HoppRESTRequest).name,
JSON.stringify(requestNew)
)
}
},
async saveRequestAs({ path, request }) {
const folder = navigateToFolderWithIndexPath(
graphqlCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)
const parentCollectionBackendID = folder?.id
if (parentCollectionBackendID) {
const newRequest = folder.requests[folder.requests.length - 1]
const res = await createGQLUserRequest(
(request as HoppRESTRequest).name,
JSON.stringify(request),
parentCollectionBackendID
)
if (E.isRight(res)) {
const { id } = res.right.createGQLUserRequest
newRequest.id = id
removeDuplicateGraphqlCollectionOrFolder(
id,
`${path}/${folder.requests.length - 1}`,
"request"
)
}
}
},
async removeRequest({ requestID }) {
if (requestID) {
await deleteUserRequest(requestID)
}
},
moveRequest({ destinationPath, path, requestIndex }) {
moveOrReorderRequests(requestIndex, path, destinationPath, undefined, "GQL")
},
}
export const gqlCollectionsSyncer = getSyncInitFunction(
graphqlCollectionStore,
storeSyncDefinition,
() => settingsStore.value.syncCollections,
getSettingSubject("syncCollections")
)

View File

@@ -0,0 +1,117 @@
import {
runMutation,
runGQLQuery,
runGQLSubscription,
} from "@hoppscotch/common/helpers/backend/GQLClient"
import {
CreateUserEnvironmentDocument,
CreateUserEnvironmentMutation,
CreateUserEnvironmentMutationVariables,
UpdateUserEnvironmentMutation,
UpdateUserEnvironmentMutationVariables,
UpdateUserEnvironmentDocument,
DeleteUserEnvironmentMutation,
DeleteUserEnvironmentMutationVariables,
DeleteUserEnvironmentDocument,
ClearGlobalEnvironmentsMutation,
ClearGlobalEnvironmentsMutationVariables,
ClearGlobalEnvironmentsDocument,
CreateUserGlobalEnvironmentMutation,
CreateUserGlobalEnvironmentMutationVariables,
CreateUserGlobalEnvironmentDocument,
GetGlobalEnvironmentsDocument,
GetGlobalEnvironmentsQueryVariables,
GetGlobalEnvironmentsQuery,
GetUserEnvironmentsDocument,
UserEnvironmentCreatedDocument,
UserEnvironmentUpdatedDocument,
UserEnvironmentDeletedDocument,
} from "./../../api/generated/graphql"
import { Environment } from "@hoppscotch/data"
export const createUserEnvironment = (name: string, variables: string) =>
runMutation<
CreateUserEnvironmentMutation,
CreateUserEnvironmentMutationVariables,
""
>(CreateUserEnvironmentDocument, {
name,
variables,
})()
export const updateUserEnvironment = (
id: string,
{ name, variables }: Environment
) =>
runMutation<
UpdateUserEnvironmentMutation,
UpdateUserEnvironmentMutationVariables,
""
>(UpdateUserEnvironmentDocument, {
id,
name,
variables: JSON.stringify(variables),
})
export const deleteUserEnvironment = (id: string) =>
runMutation<
DeleteUserEnvironmentMutation,
DeleteUserEnvironmentMutationVariables,
""
>(DeleteUserEnvironmentDocument, {
id,
})
export const clearGlobalEnvironmentVariables = (id: string) =>
runMutation<
ClearGlobalEnvironmentsMutation,
ClearGlobalEnvironmentsMutationVariables,
""
>(ClearGlobalEnvironmentsDocument, {
id,
})()
export const getUserEnvironments = () =>
runGQLQuery({
query: GetUserEnvironmentsDocument,
variables: {},
})
export const getGlobalEnvironments = () =>
runGQLQuery<
GetGlobalEnvironmentsQuery,
GetGlobalEnvironmentsQueryVariables,
"user_environment/user_env_does_not_exists"
>({
query: GetGlobalEnvironmentsDocument,
variables: {},
})
export const createUserGlobalEnvironment = (variables: string) =>
runMutation<
CreateUserGlobalEnvironmentMutation,
CreateUserGlobalEnvironmentMutationVariables,
""
>(CreateUserGlobalEnvironmentDocument, {
variables,
})()
export const runUserEnvironmentCreatedSubscription = () =>
runGQLSubscription({
query: UserEnvironmentCreatedDocument,
variables: {},
})
export const runUserEnvironmentUpdatedSubscription = () =>
runGQLSubscription({
query: UserEnvironmentUpdatedDocument,
variables: {},
})
export const runUserEnvironmentDeletedSubscription = () =>
runGQLSubscription({
query: UserEnvironmentDeletedDocument,
variables: {},
})

View File

@@ -0,0 +1,200 @@
import { authEvents$, def as platformAuth } from "@platform/auth"
import {
createEnvironment,
deleteEnvironment,
environmentsStore,
getLocalIndexByEnvironmentID,
replaceEnvironments,
setGlobalEnvID,
setGlobalEnvVariables,
updateEnvironment,
} from "@hoppscotch/common/newstore/environments"
import { EnvironmentsPlatformDef } from "@hoppscotch/common/src/platform/environments"
import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient"
import { environnmentsSyncer } from "@platform/environments/environments.sync"
import * as E from "fp-ts/Either"
import { runDispatchWithOutSyncing } from "@lib/sync"
import {
createUserGlobalEnvironment,
getGlobalEnvironments,
getUserEnvironments,
runUserEnvironmentCreatedSubscription,
runUserEnvironmentDeletedSubscription,
runUserEnvironmentUpdatedSubscription,
} from "@platform/environments/environments.api"
export function initEnvironmentsSync() {
const currentUser$ = platformAuth.getCurrentUserStream()
environnmentsSyncer.startStoreSync()
environnmentsSyncer.setupSubscriptions(setupSubscriptions)
currentUser$.subscribe(async (user) => {
if (user) {
await loadAllEnvironments()
}
})
authEvents$.subscribe((event) => {
if (event.event == "login" || event.event == "token_refresh") {
environnmentsSyncer.startListeningToSubscriptions()
}
if (event.event == "logout") {
environnmentsSyncer.stopListeningToSubscriptions()
}
})
}
export const def: EnvironmentsPlatformDef = {
initEnvironmentsSync,
}
function setupSubscriptions() {
let subs: ReturnType<typeof runGQLSubscription>[1][] = []
const userEnvironmentCreatedSub = setupUserEnvironmentCreatedSubscription()
const userEnvironmentUpdatedSub = setupUserEnvironmentUpdatedSubscription()
const userEnvironmentDeletedSub = setupUserEnvironmentDeletedSubscription()
subs = [
userEnvironmentCreatedSub,
userEnvironmentUpdatedSub,
userEnvironmentDeletedSub,
]
return () => {
subs.forEach((sub) => sub.unsubscribe())
}
}
async function loadUserEnvironments() {
const res = await getUserEnvironments()
if (E.isRight(res)) {
const environments = res.right.me.environments
if (environments.length > 0) {
runDispatchWithOutSyncing(() => {
replaceEnvironments(
environments.map(({ id, variables, name }) => ({
id,
name,
variables: JSON.parse(variables),
}))
)
})
}
}
}
async function loadGlobalEnvironments() {
const res = await getGlobalEnvironments()
if (E.isRight(res)) {
const globalEnv = res.right.me.globalEnvironments
if (globalEnv) {
runDispatchWithOutSyncing(() => {
setGlobalEnvVariables(JSON.parse(globalEnv.variables))
setGlobalEnvID(globalEnv.id)
})
}
} else if (res.left.error == "user_environment/user_env_does_not_exists") {
const res = await createUserGlobalEnvironment(JSON.stringify([]))
if (E.isRight(res)) {
const backendId = res.right.createUserGlobalEnvironment.id
setGlobalEnvID(backendId)
}
}
}
async function loadAllEnvironments() {
await loadUserEnvironments()
await loadGlobalEnvironments()
}
function setupUserEnvironmentCreatedSubscription() {
const [userEnvironmentCreated$, userEnvironmentCreatedSub] =
runUserEnvironmentCreatedSubscription()
userEnvironmentCreated$.subscribe((res) => {
if (E.isRight(res)) {
const { name, variables, id } = res.right.userEnvironmentCreated
const isAlreadyExisting = environmentsStore.value.environments.some(
(env) => env.id == id
)
if (name && !isAlreadyExisting) {
runDispatchWithOutSyncing(() => {
createEnvironment(name, JSON.parse(variables), id)
})
}
}
})
return userEnvironmentCreatedSub
}
function setupUserEnvironmentUpdatedSubscription() {
const [userEnvironmentUpdated$, userEnvironmentUpdatedSub] =
runUserEnvironmentUpdatedSubscription()
userEnvironmentUpdated$.subscribe((res) => {
if (E.isRight(res)) {
const { name, variables, id, isGlobal } = res.right.userEnvironmentUpdated
// handle the case for global environments
if (isGlobal) {
runDispatchWithOutSyncing(() => {
setGlobalEnvVariables(JSON.parse(variables))
})
} else {
// handle the case for normal environments
const localIndex = environmentsStore.value.environments.findIndex(
(env) => env.id == id
)
if ((localIndex || localIndex == 0) && name) {
runDispatchWithOutSyncing(() => {
updateEnvironment(localIndex, {
id,
name,
variables: JSON.parse(variables),
})
})
}
}
}
})
return userEnvironmentUpdatedSub
}
function setupUserEnvironmentDeletedSubscription() {
const [userEnvironmentDeleted$, userEnvironmentDeletedSub] =
runUserEnvironmentDeletedSubscription()
userEnvironmentDeleted$.subscribe((res) => {
if (E.isRight(res)) {
const { id } = res.right.userEnvironmentDeleted
// TODO: move getLocalIndexByID to a getter in the environmentsStore
const localIndex = getLocalIndexByEnvironmentID(id)
if (localIndex || localIndex === 0) {
runDispatchWithOutSyncing(() => {
deleteEnvironment(localIndex)
})
}
}
})
return userEnvironmentDeletedSub
}

View File

@@ -0,0 +1,117 @@
import {
environmentsStore,
getGlobalVariableID,
removeDuplicateEntry,
} from "@hoppscotch/common/newstore/environments"
import {
getSettingSubject,
settingsStore,
} from "@hoppscotch/common/newstore/settings"
import { getSyncInitFunction } from "../../lib/sync"
import * as E from "fp-ts/Either"
import { StoreSyncDefinitionOf } from "../../lib/sync"
import { createMapper } from "../../lib/sync/mapper"
import {
clearGlobalEnvironmentVariables,
createUserEnvironment,
deleteUserEnvironment,
updateUserEnvironment,
} from "./environments.api"
export const environmentsMapper = createMapper<number, string>()
export const globalEnvironmentMapper = createMapper<number, string>()
export const storeSyncDefinition: StoreSyncDefinitionOf<
typeof environmentsStore
> = {
async createEnvironment({ name, variables }) {
const lastCreatedEnvIndex = environmentsStore.value.environments.length - 1
const res = await createUserEnvironment(name, JSON.stringify(variables))
if (E.isRight(res)) {
const id = res.right.createUserEnvironment.id
environmentsStore.value.environments[lastCreatedEnvIndex].id = id
removeDuplicateEntry(id)
}
},
async appendEnvironments({ envs }) {
const appendListLength = envs.length
let appendStart =
environmentsStore.value.environments.length - appendListLength - 1
envs.forEach((env) => {
const envId = ++appendStart
;(async function () {
const res = await createUserEnvironment(
env.name,
JSON.stringify(env.variables)
)
if (E.isRight(res)) {
const id = res.right.createUserEnvironment.id
environmentsStore.value.environments[envId].id = id
removeDuplicateEntry(id)
}
})()
})
},
async duplicateEnvironment({ envIndex }) {
const environmentToDuplicate = environmentsStore.value.environments.find(
(_, index) => index === envIndex
)
const lastCreatedEnvIndex = environmentsStore.value.environments.length - 1
if (environmentToDuplicate) {
const res = await createUserEnvironment(
`${environmentToDuplicate?.name} - Duplicate`,
JSON.stringify(environmentToDuplicate?.variables)
)
if (E.isRight(res)) {
const id = res.right.createUserEnvironment.id
environmentsStore.value.environments[lastCreatedEnvIndex].id = id
removeDuplicateEntry(id)
}
}
},
updateEnvironment({ envIndex, updatedEnv }) {
const backendId = environmentsStore.value.environments[envIndex].id
if (backendId) {
updateUserEnvironment(backendId, updatedEnv)()
}
},
async deleteEnvironment({ envID }) {
if (envID) {
await deleteUserEnvironment(envID)()
}
},
setGlobalVariables({ entries }) {
const backendId = getGlobalVariableID()
if (backendId) {
updateUserEnvironment(backendId, { name: "", variables: entries })()
}
},
clearGlobalVariables() {
const backendId = getGlobalVariableID()
if (backendId) {
clearGlobalEnvironmentVariables(backendId)
}
},
}
export const environnmentsSyncer = getSyncInitFunction(
environmentsStore,
storeSyncDefinition,
() => settingsStore.value.syncEnvironments,
getSettingSubject("syncEnvironments")
)

View File

@@ -0,0 +1,100 @@
import {
runMutation,
runGQLQuery,
runGQLSubscription,
} from "@hoppscotch/common/helpers/backend/GQLClient"
import {
CreateUserHistoryDocument,
CreateUserHistoryMutation,
CreateUserHistoryMutationVariables,
DeleteAllUserHistoryDocument,
DeleteAllUserHistoryMutation,
DeleteAllUserHistoryMutationVariables,
GetRestUserHistoryDocument,
GetRestUserHistoryQuery,
GetRestUserHistoryQueryVariables,
RemoveRequestFromHistoryDocument,
RemoveRequestFromHistoryMutation,
RemoveRequestFromHistoryMutationVariables,
ReqType,
ToggleHistoryStarStatusDocument,
ToggleHistoryStarStatusMutation,
ToggleHistoryStarStatusMutationVariables,
UserHistoryCreatedDocument,
UserHistoryDeletedDocument,
UserHistoryDeletedManyDocument,
UserHistoryUpdatedDocument,
} from "../../api/generated/graphql"
export const getUserHistoryEntries = () =>
runGQLQuery<GetRestUserHistoryQuery, GetRestUserHistoryQueryVariables, "">({
query: GetRestUserHistoryDocument,
variables: {},
})
export const createUserHistory = (
reqData: string,
resMetadata: string,
reqType: ReqType
) =>
runMutation<
CreateUserHistoryMutation,
CreateUserHistoryMutationVariables,
""
>(CreateUserHistoryDocument, {
reqData,
resMetadata,
reqType,
})()
export const toggleHistoryStarStatus = (id: string) =>
runMutation<
ToggleHistoryStarStatusMutation,
ToggleHistoryStarStatusMutationVariables,
""
>(ToggleHistoryStarStatusDocument, {
id,
})()
export const removeRequestFromHistory = (id: string) =>
runMutation<
RemoveRequestFromHistoryMutation,
RemoveRequestFromHistoryMutationVariables,
""
>(RemoveRequestFromHistoryDocument, {
id,
})()
export const deleteAllUserHistory = (reqType: ReqType) =>
runMutation<
DeleteAllUserHistoryMutation,
DeleteAllUserHistoryMutationVariables,
""
>(DeleteAllUserHistoryDocument, {
reqType,
})()
export const runUserHistoryCreatedSubscription = () =>
runGQLSubscription({
query: UserHistoryCreatedDocument,
variables: {},
})
export const runUserHistoryUpdatedSubscription = () =>
runGQLSubscription({
query: UserHistoryUpdatedDocument,
variables: {},
})
export const runUserHistoryDeletedSubscription = () =>
runGQLSubscription({
query: UserHistoryDeletedDocument,
variables: {},
})
export const runUserHistoryDeletedManySubscription = () =>
runGQLSubscription({
query: UserHistoryDeletedManyDocument,
variables: {},
})

View File

@@ -0,0 +1,261 @@
import { authEvents$, def as platformAuth } from "@platform/auth"
import {
restHistoryStore,
RESTHistoryEntry,
setRESTHistoryEntries,
addRESTHistoryEntry,
toggleRESTHistoryEntryStar,
deleteRESTHistoryEntry,
clearRESTHistory,
setGraphqlHistoryEntries,
GQLHistoryEntry,
addGraphqlHistoryEntry,
toggleGraphqlHistoryEntryStar,
graphqlHistoryStore,
deleteGraphqlHistoryEntry,
clearGraphqlHistory,
} from "@hoppscotch/common/newstore/history"
import { HistoryPlatformDef } from "@hoppscotch/common/platform/history"
import {
getUserHistoryEntries,
runUserHistoryCreatedSubscription,
runUserHistoryDeletedManySubscription,
runUserHistoryDeletedSubscription,
runUserHistoryUpdatedSubscription,
} from "./history.api"
import * as E from "fp-ts/Either"
import { restHistorySyncer, gqlHistorySyncer } from "./history.sync"
import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient"
import { runDispatchWithOutSyncing } from "@lib/sync"
import { ReqType } from "../../api/generated/graphql"
function initHistorySync() {
const currentUser$ = platformAuth.getCurrentUserStream()
restHistorySyncer.startStoreSync()
restHistorySyncer.setupSubscriptions(setupSubscriptions)
gqlHistorySyncer.startStoreSync()
loadHistoryEntries()
currentUser$.subscribe(async (user) => {
if (user) {
await loadHistoryEntries()
}
})
authEvents$.subscribe((event) => {
if (event.event == "login" || event.event == "token_refresh") {
restHistorySyncer.startListeningToSubscriptions()
}
if (event.event == "logout") {
restHistorySyncer.stopListeningToSubscriptions()
}
})
}
function setupSubscriptions() {
let subs: ReturnType<typeof runGQLSubscription>[1][] = []
const userHistoryCreatedSub = setupUserHistoryCreatedSubscription()
const userHistoryUpdatedSub = setupUserHistoryUpdatedSubscription()
const userHistoryDeletedSub = setupUserHistoryDeletedSubscription()
const userHistoryDeletedManySub = setupUserHistoryDeletedManySubscription()
subs = [
userHistoryCreatedSub,
userHistoryUpdatedSub,
userHistoryDeletedSub,
userHistoryDeletedManySub,
]
return () => {
subs.forEach((sub) => sub.unsubscribe())
}
}
async function loadHistoryEntries() {
const res = await getUserHistoryEntries()
if (E.isRight(res)) {
const restEntries = res.right.me.RESTHistory
const gqlEntries = res.right.me.GQLHistory
const restHistoryEntries: RESTHistoryEntry[] = restEntries.map((entry) => ({
v: 1,
request: JSON.parse(entry.request),
responseMeta: JSON.parse(entry.responseMetadata),
star: entry.isStarred,
updatedOn: new Date(entry.executedOn),
id: entry.id,
}))
const gqlHistoryEntries: GQLHistoryEntry[] = gqlEntries.map((entry) => ({
v: 1,
request: JSON.parse(entry.request),
response: JSON.parse(entry.responseMetadata),
star: entry.isStarred,
updatedOn: new Date(entry.executedOn),
id: entry.id,
}))
runDispatchWithOutSyncing(() => {
setRESTHistoryEntries(restHistoryEntries)
setGraphqlHistoryEntries(gqlHistoryEntries)
})
}
}
function setupUserHistoryCreatedSubscription() {
const [userHistoryCreated$, userHistoryCreatedSub] =
runUserHistoryCreatedSubscription()
userHistoryCreated$.subscribe((res) => {
if (E.isRight(res)) {
const { id, reqType, request, responseMetadata, isStarred, executedOn } =
res.right.userHistoryCreated
const hasAlreadyBeenAdded =
reqType == ReqType.Rest
? restHistoryStore.value.state.some((entry) => entry.id == id)
: graphqlHistoryStore.value.state.some((entry) => entry.id == id)
!hasAlreadyBeenAdded &&
runDispatchWithOutSyncing(() => {
reqType == ReqType.Rest
? addRESTHistoryEntry({
v: 1,
id,
request: JSON.parse(request),
responseMeta: JSON.parse(responseMetadata),
star: isStarred,
updatedOn: new Date(executedOn),
})
: addGraphqlHistoryEntry({
v: 1,
id,
request: JSON.parse(request),
response: JSON.parse(responseMetadata),
star: isStarred,
updatedOn: new Date(executedOn),
})
})
}
})
return userHistoryCreatedSub
}
// currently the updates are only for toggling the star
function setupUserHistoryUpdatedSubscription() {
const [userHistoryUpdated$, userHistoryUpdatedSub] =
runUserHistoryUpdatedSubscription()
userHistoryUpdated$.subscribe((res) => {
if (E.isRight(res)) {
const { id, executedOn, isStarred, request, responseMetadata, reqType } =
res.right.userHistoryUpdated
if (reqType == ReqType.Rest) {
const updatedRestEntryIndex = restHistoryStore.value.state.findIndex(
(entry) => entry.id == id
)
if (updatedRestEntryIndex != -1) {
runDispatchWithOutSyncing(() => {
toggleRESTHistoryEntryStar({
v: 1,
id,
request: JSON.parse(request),
responseMeta: JSON.parse(responseMetadata),
// because the star will be toggled in the store, we need to pass the opposite value
star: !isStarred,
updatedOn: new Date(executedOn),
})
})
}
}
if (reqType == ReqType.Gql) {
const updatedGQLEntryIndex = graphqlHistoryStore.value.state.findIndex(
(entry) => entry.id == id
)
if (updatedGQLEntryIndex != -1) {
runDispatchWithOutSyncing(() => {
toggleGraphqlHistoryEntryStar({
v: 1,
id,
request: JSON.parse(request),
response: JSON.parse(responseMetadata),
// because the star will be toggled in the store, we need to pass the opposite value
star: !isStarred,
updatedOn: new Date(executedOn),
})
})
}
}
}
})
return userHistoryUpdatedSub
}
function setupUserHistoryDeletedSubscription() {
const [userHistoryDeleted$, userHistoryDeletedSub] =
runUserHistoryDeletedSubscription()
userHistoryDeleted$.subscribe((res) => {
if (E.isRight(res)) {
const { id, reqType } = res.right.userHistoryDeleted
if (reqType == ReqType.Gql) {
const deletedEntry = graphqlHistoryStore.value.state.find(
(entry) => entry.id == id
)
deletedEntry &&
runDispatchWithOutSyncing(() => {
deleteGraphqlHistoryEntry(deletedEntry)
})
}
if (reqType == ReqType.Rest) {
const deletedEntry = restHistoryStore.value.state.find(
(entry) => entry.id == id
)
deletedEntry &&
runDispatchWithOutSyncing(() => {
deleteRESTHistoryEntry(deletedEntry)
})
}
}
})
return userHistoryDeletedSub
}
function setupUserHistoryDeletedManySubscription() {
const [userHistoryDeletedMany$, userHistoryDeletedManySub] =
runUserHistoryDeletedManySubscription()
userHistoryDeletedMany$.subscribe((res) => {
if (E.isRight(res)) {
const { reqType } = res.right.userHistoryDeletedMany
runDispatchWithOutSyncing(() => {
reqType == ReqType.Rest ? clearRESTHistory() : clearGraphqlHistory()
})
}
})
return userHistoryDeletedManySub
}
export const def: HistoryPlatformDef = {
initHistorySync,
}

View File

@@ -0,0 +1,101 @@
import {
graphqlHistoryStore,
removeDuplicateRestHistoryEntry,
removeDuplicateGraphqlHistoryEntry,
restHistoryStore,
} from "@hoppscotch/common/newstore/history"
import {
getSettingSubject,
settingsStore,
} from "@hoppscotch/common/newstore/settings"
import { getSyncInitFunction } from "../../lib/sync"
import * as E from "fp-ts/Either"
import { StoreSyncDefinitionOf } from "../../lib/sync"
import {
createUserHistory,
deleteAllUserHistory,
removeRequestFromHistory,
toggleHistoryStarStatus,
} from "./history.api"
import { ReqType } from "../../api/generated/graphql"
export const restHistoryStoreSyncDefinition: StoreSyncDefinitionOf<
typeof restHistoryStore
> = {
async addEntry({ entry }) {
const res = await createUserHistory(
JSON.stringify(entry.request),
JSON.stringify(entry.responseMeta),
ReqType.Rest
)
if (E.isRight(res)) {
entry.id = res.right.createUserHistory.id
// preventing double insertion from here and subscription
removeDuplicateRestHistoryEntry(entry.id)
}
},
deleteEntry({ entry }) {
if (entry.id) {
removeRequestFromHistory(entry.id)
}
},
toggleStar({ entry }) {
if (entry.id) {
toggleHistoryStarStatus(entry.id)
}
},
clearHistory() {
deleteAllUserHistory(ReqType.Rest)
},
}
export const gqlHistoryStoreSyncDefinition: StoreSyncDefinitionOf<
typeof graphqlHistoryStore
> = {
async addEntry({ entry }) {
const res = await createUserHistory(
JSON.stringify(entry.request),
JSON.stringify(entry.response),
ReqType.Gql
)
if (E.isRight(res)) {
entry.id = res.right.createUserHistory.id
// preventing double insertion from here and subscription
removeDuplicateGraphqlHistoryEntry(entry.id)
}
},
deleteEntry({ entry }) {
if (entry.id) {
removeRequestFromHistory(entry.id)
}
},
toggleStar({ entry }) {
if (entry.id) {
toggleHistoryStarStatus(entry.id)
}
},
clearHistory() {
deleteAllUserHistory(ReqType.Gql)
},
}
export const restHistorySyncer = getSyncInitFunction(
restHistoryStore,
restHistoryStoreSyncDefinition,
() => settingsStore.value.syncHistory,
getSettingSubject("syncHistory")
)
export const gqlHistorySyncer = getSyncInitFunction(
graphqlHistoryStore,
gqlHistoryStoreSyncDefinition,
() => settingsStore.value.syncHistory,
getSettingSubject("syncHistory")
)

View File

@@ -0,0 +1,169 @@
import * as E from "fp-ts/Either"
import {
Interceptor,
InterceptorError,
RequestRunResult,
} from "@hoppscotch/common/services/interceptor.service"
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
import axios, { AxiosRequestConfig, CancelToken } from "axios"
import { cloneDeep } from "lodash-es"
import { Body, HttpVerb, ResponseType, getClient } from "@tauri-apps/api/http"
import { Service } from "dioc"
export const preProcessRequest = (
req: AxiosRequestConfig
): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents issues of marshalling the URLSearchParams to the proxy
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {
// making this a non-empty block, so we can make the linter happy.
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
}
reqClone.params = {}
}
return reqClone
}
async function runRequest(
req: AxiosRequestConfig,
cancelled: () => boolean
): RequestRunResult["response"] {
const timeStart = Date.now()
const processedReq = preProcessRequest(req)
try {
const client = await getClient()
if (cancelled()) {
client.drop()
return E.left("cancellation")
}
let body = Body.text(processedReq.data ?? "")
if (processedReq.data instanceof FormData) {
let body_data = {}
for (const entry of processedReq.data.entries()) {
const [name, value] = entry
if (value instanceof File) {
let file_data = await value.arrayBuffer()
body_data[name] = {
file: new Uint8Array(file_data),
fileName: value.name,
}
}
}
body = Body.form(body_data)
}
const res = await client.request({
method: processedReq.method as HttpVerb,
url: processedReq.url ?? "",
responseType: ResponseType.Binary,
headers: processedReq.headers,
body: body,
})
if (cancelled()) {
client.drop()
return E.left("cancellation")
}
res.data = new Uint8Array(res.data as number[]).buffer
const timeEnd = Date.now()
return E.right({
...res,
config: {
timeData: {
startTime: timeStart,
endTime: timeEnd,
},
},
})
} catch (e) {
const timeEnd = Date.now()
if (axios.isAxiosError(e) && e.response) {
return E.right({
...e.response,
config: {
timeData: {
startTime: timeStart,
endTime: timeEnd,
},
},
})
} else if (axios.isCancel(e)) {
return E.left("cancellation")
} else {
return E.left(<InterceptorError>{
humanMessage: {
heading: (t) => t("error.network_fail"),
description: (t) => t("helpers.network_fail"),
},
error: e,
})
}
}
}
export class NativeInterceptorService extends Service implements Interceptor {
public static readonly ID = "NATIVE_INTERCEPTOR_SERVICE"
public interceptorID = "native" // TODO: i18n this
public name = () => "Native"
public selectable = { type: "selectable" as const }
public supportsCookies = true
public cookieJarService = this.bind(CookieJarService)
constructor() {
super()
}
public runRequest(req: any) {
const processedReq = preProcessRequest(req)
const relevantCookies = this.cookieJarService.getCookiesForURL(
new URL(processedReq.url!)
)
processedReq.headers["Cookie"] = relevantCookies
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
.join(";")
let cancelled = false
const checkCancelled = () => {
return cancelled
}
return {
cancel: () => {
cancelled = true
},
response: runRequest(processedReq, checkCancelled),
}
}
}

View File

@@ -0,0 +1,24 @@
import { IOPlatformDef } from "@hoppscotch/common/platform/io"
import { save } from "@tauri-apps/api/dialog"
import { writeBinaryFile, writeTextFile } from "@tauri-apps/api/fs"
export const ioDef: IOPlatformDef = {
async saveFileWithDialog(opts) {
const path = await save({
filters: opts.filters,
defaultPath: opts.suggestedFilename,
})
if (path === null) {
return { type: "cancelled" }
}
if (typeof opts.data === "string") {
await writeTextFile(path, opts.data)
} else {
await writeBinaryFile(path, opts.data)
}
return { type: "saved", path }
},
}

View File

@@ -0,0 +1,51 @@
import {
runGQLQuery,
runGQLSubscription,
runMutation,
} from "@hoppscotch/common/helpers/backend/GQLClient"
import {
CreateUserSettingsDocument,
CreateUserSettingsMutation,
CreateUserSettingsMutationVariables,
GetUserSettingsDocument,
GetUserSettingsQuery,
GetUserSettingsQueryVariables,
UpdateUserSettingsDocument,
UpdateUserSettingsMutation,
UpdateUserSettingsMutationVariables,
UserSettingsUpdatedDocument,
} from "../../api/generated/graphql"
export const getUserSettings = () =>
runGQLQuery<
GetUserSettingsQuery,
GetUserSettingsQueryVariables,
"user_settings/not_found"
>({
query: GetUserSettingsDocument,
variables: {},
})
export const createUserSettings = (properties: string) =>
runMutation<
CreateUserSettingsMutation,
CreateUserSettingsMutationVariables,
""
>(CreateUserSettingsDocument, {
properties,
})()
export const updateUserSettings = (properties: string) =>
runMutation<
UpdateUserSettingsMutation,
UpdateUserSettingsMutationVariables,
""
>(UpdateUserSettingsDocument, {
properties,
})()
export const runUserSettingsUpdatedSubscription = () =>
runGQLSubscription({
query: UserSettingsUpdatedDocument,
variables: {},
})

View File

@@ -0,0 +1,88 @@
import { SettingsPlatformDef } from "@hoppscotch/common/platform/settings"
import { settingsSyncer } from "./settings.sync"
import { authEvents$, def as platformAuth } from "@platform/auth"
import {
createUserSettings,
getUserSettings,
runUserSettingsUpdatedSubscription,
} from "./settings.api"
import * as E from "fp-ts/Either"
import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient"
import {
bulkApplySettings,
getDefaultSettings,
} from "@hoppscotch/common/newstore/settings"
import { runDispatchWithOutSyncing } from "@lib/sync"
function initSettingsSync() {
const currentUser$ = platformAuth.getCurrentUserStream()
settingsSyncer.startStoreSync()
settingsSyncer.setupSubscriptions(setupSubscriptions)
// load the settings
loadUserSettings()
currentUser$.subscribe(async (user) => {
if (user) {
// load the settings
loadUserSettings()
}
})
authEvents$.subscribe((event) => {
if (event.event == "login" || event.event == "token_refresh") {
settingsSyncer.startListeningToSubscriptions()
}
if (event.event == "logout") {
settingsSyncer.stopListeningToSubscriptions()
}
})
}
async function loadUserSettings() {
const res = await getUserSettings()
// create user settings if it doesn't exist
E.isLeft(res) &&
res.left.error == "user_settings/not_found" &&
(await createUserSettings(JSON.stringify(getDefaultSettings())))
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
bulkApplySettings(JSON.parse(res.right.me.settings.properties))
})
}
}
function setupSubscriptions() {
let subs: ReturnType<typeof runGQLSubscription>[1][] = []
const userSettingsUpdatedSub = setupUserSettingsUpdatedSubscription()
subs = [userSettingsUpdatedSub]
return () => {
subs.forEach((sub) => sub.unsubscribe())
}
}
function setupUserSettingsUpdatedSubscription() {
const [userSettingsUpdated$, userSettingsUpdatedSub] =
runUserSettingsUpdatedSubscription()
userSettingsUpdated$.subscribe((res) => {
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
bulkApplySettings(JSON.parse(res.right.userSettingsUpdated.properties))
})
}
})
return userSettingsUpdatedSub
}
export const def: SettingsPlatformDef = {
initSettingsSync,
}

View File

@@ -0,0 +1,21 @@
import { settingsStore } from "@hoppscotch/common/newstore/settings"
import { getSyncInitFunction } from "../../lib/sync"
import { StoreSyncDefinitionOf } from "../../lib/sync"
import { updateUserSettings } from "./settings.api"
export const settingsSyncDefinition: StoreSyncDefinitionOf<
typeof settingsStore
> = {
applySetting() {
updateUserSettings(JSON.stringify(settingsStore.value))
},
}
export const settingsSyncer = getSyncInitFunction(
settingsStore,
settingsSyncDefinition,
() => true
)

View File

@@ -0,0 +1,36 @@
import {
runMutation,
runGQLQuery,
} from "@hoppscotch/common/helpers/backend/GQLClient"
import {
GetCurrentRestSessionDocument,
GetCurrentRestSessionQuery,
GetCurrentRestSessionQueryVariables,
SessionType,
UpdateUserSessionDocument,
UpdateUserSessionMutation,
UpdateUserSessionMutationVariables,
} from "../../api/generated/graphql"
export const updateUserSession = (
currentSession: string,
sessionType: SessionType
) =>
runMutation<
UpdateUserSessionMutation,
UpdateUserSessionMutationVariables,
""
>(UpdateUserSessionDocument, {
sessionType,
currentSession,
})()
export const getCurrentRestSession = () =>
runGQLQuery<
GetCurrentRestSessionQuery,
GetCurrentRestSessionQueryVariables,
""
>({
query: GetCurrentRestSessionDocument,
variables: {},
})

View File

@@ -0,0 +1,37 @@
import { PersistableRESTTabState } from "@hoppscotch/common/helpers/rest/tab"
import { HoppUser } from "@hoppscotch/common/platform/auth"
import { TabStatePlatformDef } from "@hoppscotch/common/platform/tab"
import { def as platformAuth } from "@platform/auth"
import { getCurrentRestSession, updateUserSession } from "./tabState.api"
import { SessionType } from "../../api/generated/graphql"
import * as E from "fp-ts/Either"
async function writeCurrentTabState(
_: HoppUser,
persistableTabState: PersistableRESTTabState
) {
await updateUserSession(JSON.stringify(persistableTabState), SessionType.Rest)
}
async function loadTabStateFromSync(): Promise<PersistableRESTTabState | null> {
const currentUser = platformAuth.getCurrentUser()
if (!currentUser)
throw new Error("Cannot load request from sync without login")
const res = await getCurrentRestSession()
if (E.isRight(res)) {
const currentRESTSession = res.right.me.currentRESTSession
return currentRESTSession ? JSON.parse(currentRESTSession) : null
} else {
}
return null
}
export const def: TabStatePlatformDef = {
loadTabStateFromSync,
writeCurrentTabState,
}