From 8586ced3cc85c12dacc6c57430a5bc8577f6b068 Mon Sep 17 00:00:00 2001 From: Akash K <57758277+amk-dev@users.noreply.github.com> Date: Sat, 1 Apr 2023 18:24:58 +0530 Subject: [PATCH] feat: implement user history syncing for selfhost (#60) --- .../api/mutations/CreateUserHistory.graphql | 13 + .../mutations/DeleteAllUserHistory.graphql | 6 + .../RemoveRequestFromHistory.graphql | 5 + .../mutations/ToggleHistoryStarStatus.graphql | 5 + .../api/queries/GetRestUserHistory.graphql | 23 ++ .../subscriptions/UserHistoryCreated.graphql | 10 + .../subscriptions/UserHistoryDeleted.graphql | 6 + .../UserHistoryDeletedMany.graphql | 6 + .../subscriptions/UserHistoryUpdated.graphql | 10 + packages/hoppscotch-selfhost-web/src/main.ts | 2 + .../src/platform/history/history.api.ts | 95 +++++++ .../src/platform/history/history.platform.ts | 261 ++++++++++++++++++ .../src/platform/history/history.sync.ts | 101 +++++++ 13 files changed, 543 insertions(+) create mode 100644 packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserHistory.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/mutations/DeleteAllUserHistory.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/mutations/RemoveRequestFromHistory.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/mutations/ToggleHistoryStarStatus.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/queries/GetRestUserHistory.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryCreated.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeleted.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeletedMany.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryUpdated.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/platform/history/history.api.ts create mode 100644 packages/hoppscotch-selfhost-web/src/platform/history/history.platform.ts create mode 100644 packages/hoppscotch-selfhost-web/src/platform/history/history.sync.ts diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserHistory.graphql new file mode 100644 index 000000000..ef9da5c66 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserHistory.graphql @@ -0,0 +1,13 @@ +mutation CreateUserHistory( + $reqData: String! + $resMetadata: String! + $reqType: ReqType! +) { + createUserHistory( + reqData: $reqData + resMetadata: $resMetadata + reqType: $reqType + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteAllUserHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteAllUserHistory.graphql new file mode 100644 index 000000000..3e5085870 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteAllUserHistory.graphql @@ -0,0 +1,6 @@ +mutation DeleteAllUserHistory($reqType: ReqType!) { + deleteAllUserHistory(reqType: $reqType) { + count + reqType + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/RemoveRequestFromHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/RemoveRequestFromHistory.graphql new file mode 100644 index 000000000..a0dd3e988 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/RemoveRequestFromHistory.graphql @@ -0,0 +1,5 @@ +mutation RemoveRequestFromHistory($id: ID!) { + removeRequestFromHistory(id: $id) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/ToggleHistoryStarStatus.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/ToggleHistoryStarStatus.graphql new file mode 100644 index 000000000..33bf9c173 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/ToggleHistoryStarStatus.graphql @@ -0,0 +1,5 @@ +mutation ToggleHistoryStarStatus($id: ID!) { + toggleHistoryStarStatus(id: $id) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetRestUserHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetRestUserHistory.graphql new file mode 100644 index 000000000..144861594 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetRestUserHistory.graphql @@ -0,0 +1,23 @@ +query GetRESTUserHistory { + me { + RESTHistory { + id + userUid + reqType + request + responseMetadata + isStarred + executedOn + } + + GQLHistory { + id + userUid + reqType + request + responseMetadata + isStarred + executedOn + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryCreated.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryCreated.graphql new file mode 100644 index 000000000..f513671ab --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryCreated.graphql @@ -0,0 +1,10 @@ +subscription UserHistoryCreated { + userHistoryCreated { + id + reqType + request + responseMetadata + isStarred + executedOn + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeleted.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeleted.graphql new file mode 100644 index 000000000..ba6540fe2 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeleted.graphql @@ -0,0 +1,6 @@ +subscription userHistoryDeleted { + userHistoryDeleted { + id + reqType + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeletedMany.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeletedMany.graphql new file mode 100644 index 000000000..fae7aca62 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryDeletedMany.graphql @@ -0,0 +1,6 @@ +subscription UserHistoryDeletedMany { + userHistoryDeletedMany { + count + reqType + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryUpdated.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryUpdated.graphql new file mode 100644 index 000000000..a673edb72 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryUpdated.graphql @@ -0,0 +1,10 @@ +subscription UserHistoryUpdated { + userHistoryUpdated { + id + reqType + request + responseMetadata + isStarred + executedOn + } +} diff --git a/packages/hoppscotch-selfhost-web/src/main.ts b/packages/hoppscotch-selfhost-web/src/main.ts index b4788d171..354d17ab6 100644 --- a/packages/hoppscotch-selfhost-web/src/main.ts +++ b/packages/hoppscotch-selfhost-web/src/main.ts @@ -3,6 +3,7 @@ import { def as authDef } from "./platform/auth" import { def as environmentsDef } from "./platform/environments/environments.platform" import { def as collectionsDef } from "./platform/collections/collections.platform" import { def as settingsDef } from "./platform/settings/settings.platform" +import { def as historyDef } from "./platform/history/history.platform" createHoppApp("#app", { auth: authDef, @@ -10,5 +11,6 @@ createHoppApp("#app", { environments: environmentsDef, collections: collectionsDef, settings: settingsDef, + history: historyDef, }, }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/history/history.api.ts b/packages/hoppscotch-selfhost-web/src/platform/history/history.api.ts new file mode 100644 index 000000000..d5a996c06 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/history/history.api.ts @@ -0,0 +1,95 @@ +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({ + query: GetRestUserHistoryDocument, + }) + +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, + }) + +export const runUserHistoryUpdatedSubscription = () => + runGQLSubscription({ + query: UserHistoryUpdatedDocument, + }) + +export const runUserHistoryDeletedSubscription = () => + runGQLSubscription({ + query: UserHistoryDeletedDocument, + }) + +export const runUserHistoryDeletedManySubscription = () => + runGQLSubscription({ + query: UserHistoryDeletedManyDocument, + }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/history/history.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/history/history.platform.ts new file mode 100644 index 000000000..fd0042a71 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/history/history.platform.ts @@ -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[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, +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/history/history.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/history/history.sync.ts new file mode 100644 index 000000000..340c185a5 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/history/history.sync.ts @@ -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") +)