From 7ac731ea7ff137382d8a211b9cc08fe6a1d5cb4b Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 14 Jan 2025 16:52:39 -0500 Subject: [PATCH 1/5] chore(api): enable introspection by default in deploy-dev script --- api/scripts/deploy-dev.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/scripts/deploy-dev.sh b/api/scripts/deploy-dev.sh index 416d90ed63..08fcb8a492 100755 --- a/api/scripts/deploy-dev.sh +++ b/api/scripts/deploy-dev.sh @@ -45,7 +45,13 @@ eval "$rsync_command" exit_code=$? # Run unraid-api restart on remote host -ssh root@"${server_name}" "unraid-api restart" +dev=${DEV:-true} + +if [ "$dev" = true ]; then + ssh root@"${server_name}" "INTROSPECTION=true unraid-api restart" +else + ssh root@"${server_name}" "unraid-api restart" +fi # Play built-in sound based on the operating system if [[ "$OSTYPE" == "darwin"* ]]; then From 9d09d0b2dec9f9ee4ea95568b96f51551fd9e45b Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 14 Jan 2025 16:54:35 -0500 Subject: [PATCH 2/5] refactor(api): load emhttp state during init so emhttp settings are always available, even at module load time. --- api/src/store/modules/emhttp.ts | 209 ++++++++++++++++---------------- 1 file changed, 105 insertions(+), 104 deletions(-) diff --git a/api/src/store/modules/emhttp.ts b/api/src/store/modules/emhttp.ts index 1d756a153d..091a7db9dd 100644 --- a/api/src/store/modules/emhttp.ts +++ b/api/src/store/modules/emhttp.ts @@ -1,28 +1,33 @@ -import { FileLoadStatus, StateFileKey, type StateFileToIniParserMap } from '@app/store/types'; -import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import merge from 'lodash/merge'; import { join } from 'path'; + +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import merge from 'lodash/merge'; + +import type { RootState } from '@app/store'; +import type { StateFileToIniParserMap } from '@app/store/types'; import { emhttpLogger } from '@app/core/log'; -import { parseConfig } from '@app/core/utils/misc/parse-config'; import { type Devices } from '@app/core/types/states/devices'; import { type Networks } from '@app/core/types/states/network'; +import { type NfsShares } from '@app/core/types/states/nfs'; import { type Nginx } from '@app/core/types/states/nginx'; import { type Shares } from '@app/core/types/states/share'; -import { type Users } from '@app/core/types/states/user'; -import { type NfsShares } from '@app/core/types/states/nfs'; import { type SmbShares } from '@app/core/types/states/smb'; +import { type Users } from '@app/core/types/states/user'; import { type Var } from '@app/core/types/states/var'; +import { parseConfig } from '@app/core/utils/misc/parse-config'; +import { type ArrayDisk } from '@app/graphql/generated/api/types'; import { parse as parseDevices } from '@app/store/state-parsers/devices'; import { parse as parseNetwork } from '@app/store/state-parsers/network'; -import { parse as parseNginx } from '@app/store/state-parsers/nginx'; import { parse as parseNfsShares } from '@app/store/state-parsers/nfs'; +import { parse as parseNginx } from '@app/store/state-parsers/nginx'; import { parse as parseShares } from '@app/store/state-parsers/shares'; import { parse as parseSlots } from '@app/store/state-parsers/slots'; import { parse as parseSmbShares } from '@app/store/state-parsers/smb'; import { parse as parseUsers } from '@app/store/state-parsers/users'; import { parse as parseVar } from '@app/store/state-parsers/var'; -import type { RootState } from '@app/store'; -import { type ArrayDisk } from '@app/graphql/generated/api/types'; +import { FileLoadStatus, StateFileKey } from '@app/store/types'; +import { paths } from './paths'; export type SliceState = { status: FileLoadStatus; @@ -37,19 +42,6 @@ export type SliceState = { nfsShares: NfsShares; }; -const initialState: SliceState = { - status: FileLoadStatus.UNLOADED, - var: {} as unknown as Var, - devices: [], - networks: [], - nginx: {} as unknown as Nginx, - shares: [], - disks: [], - users: [], - smbShares: [], - nfsShares: [], -}; - export const parsers: StateFileToIniParserMap = { [StateFileKey.var]: parseVar, [StateFileKey.devs]: parseDevices, @@ -62,14 +54,11 @@ export const parsers: StateFileToIniParserMap = { [StateFileKey.sec_nfs]: parseNfsShares, }; -const getParserFunction = ( - parser: StateFileKey -): StateFileToIniParserMap[StateFileKey] => parsers[parser]; -const parseState = < - T extends StateFileKey, - Q = ReturnType | null ->( +const getParserFunction = (parser: StateFileKey): StateFileToIniParserMap[StateFileKey] => + parsers[parser]; + +const parseState = | null>( statesDirectory: string, parser: T, defaultValue?: NonNullable @@ -100,42 +89,60 @@ const parseState = < return null as Q; }; -// @TODO Fix the type here Pick | null -export const loadSingleStateFile = createAsyncThunk< - any, - StateFileKey, - { state: RootState } ->('emhttp/load-single-state-file', async (stateFileKey, { getState }) => { - const path = getState().paths.states; +function loadState(path: string) { + return { + var: parseState(path, StateFileKey.var, {} as Var), + devices: parseState(path, StateFileKey.devs, []), + networks: parseState(path, StateFileKey.network, []), + nginx: parseState(path, StateFileKey.nginx, {} as Nginx), + shares: parseState(path, StateFileKey.shares, []), + disks: parseState(path, StateFileKey.disks, []), + users: parseState(path, StateFileKey.users, []), + smbShares: parseState(path, StateFileKey.sec, []), + nfsShares: parseState(path, StateFileKey.sec_nfs, []), + } as Omit; +} + +const initialState: SliceState = { + status: FileLoadStatus.UNLOADED, + ...loadState(paths.getInitialState().states) +}; - const config = parseState(path, stateFileKey); - if (config) { - switch (stateFileKey) { - case StateFileKey.var: - return { var: config }; - case StateFileKey.devs: - return { devices: config }; - case StateFileKey.network: - return { networks: config }; - case StateFileKey.nginx: - return { nginx: config }; - case StateFileKey.shares: - return { shares: config }; - case StateFileKey.disks: - return { disks: config }; - case StateFileKey.users: - return { users: config }; - case StateFileKey.sec: - return { smbShares: config }; - case StateFileKey.sec_nfs: - return { nfsShares: config }; - default: - return null; +// @TODO Fix the type here Pick | null +export const loadSingleStateFile = createAsyncThunk( + 'emhttp/load-single-state-file', + async (stateFileKey, { getState }) => { + const path = getState().paths.states; + + const config = parseState(path, stateFileKey); + if (config) { + switch (stateFileKey) { + case StateFileKey.var: + return { var: config }; + case StateFileKey.devs: + return { devices: config }; + case StateFileKey.network: + return { networks: config }; + case StateFileKey.nginx: + return { nginx: config }; + case StateFileKey.shares: + return { shares: config }; + case StateFileKey.disks: + return { disks: config }; + case StateFileKey.users: + return { users: config }; + case StateFileKey.sec: + return { smbShares: config }; + case StateFileKey.sec_nfs: + return { nfsShares: config }; + default: + return null; + } + } else { + return null; } - } else { - return null; } -}); +); /** * Load the emhttp states into the store. */ @@ -145,53 +152,47 @@ export const loadStateFiles = createAsyncThunk< { state: RootState } >('emhttp/load-state-file', async (_, { getState }) => { const path = getState().paths.states; - const state: Omit = { - var: parseState(path, StateFileKey.var, {} as Var), - devices: parseState(path, StateFileKey.devs, []), - networks: parseState(path, StateFileKey.network, []), - nginx: parseState(path, StateFileKey.nginx, {} as Nginx), - shares: parseState(path, StateFileKey.shares, []), - disks: parseState(path, StateFileKey.disks, []), - users: parseState(path, StateFileKey.users, []), - smbShares: parseState(path, StateFileKey.sec, []), - nfsShares: parseState(path, StateFileKey.sec_nfs, []), - }; - - return state; + return loadState(path); }); export const emhttp = createSlice({ - name: 'emhttp', - initialState, - reducers: { - updateEmhttpState(state, action: PayloadAction<{ field: StateFileKey; state: Partial }>) { - const { field } = action.payload; - return merge(state, { [field]: action.payload.state }); - }, - }, - extraReducers(builder) { - builder.addCase(loadStateFiles.pending, (state) => { - state.status = FileLoadStatus.LOADING; - }); - - builder.addCase(loadStateFiles.fulfilled, (state, action) => { - merge(state, action.payload, { status: FileLoadStatus.LOADED }); - }); - - builder.addCase(loadStateFiles.rejected, (state, action) => { - merge(state, action.payload, { status: FileLoadStatus.FAILED_LOADING }); - }); - - builder.addCase(loadSingleStateFile.fulfilled, (state, action) => { - if (action.payload) { + name: 'emhttp', + initialState, + reducers: { + updateEmhttpState( + state, + action: PayloadAction<{ + field: StateFileKey; + state: Partial<(typeof initialState)[keyof typeof initialState]>; + }> + ) { + const { field } = action.payload; + return merge(state, { [field]: action.payload.state }); + }, + }, + extraReducers(builder) { + builder.addCase(loadStateFiles.pending, (state) => { + state.status = FileLoadStatus.LOADING; + }); + + builder.addCase(loadStateFiles.fulfilled, (state, action) => { + merge(state, action.payload, { status: FileLoadStatus.LOADED }); + }); + + builder.addCase(loadStateFiles.rejected, (state, action) => { + merge(state, action.payload, { status: FileLoadStatus.FAILED_LOADING }); + }); + + builder.addCase(loadSingleStateFile.fulfilled, (state, action) => { + if (action.payload) { // const changedKey = Object.keys(action.payload)[0] // emhttpLogger.debug('Key', changedKey, 'Difference in changes', getDiff(action.payload, { [changedKey]: state[changedKey] } )) - merge(state, action.payload); - } else { - emhttpLogger.warn('Invalid payload returned from loadSingleStateFile()'); - } - }); - }, + merge(state, action.payload); + } else { + emhttpLogger.warn('Invalid payload returned from loadSingleStateFile()'); + } + }); + }, }); export const { updateEmhttpState } = emhttp.actions; From 4dec9a70bc8a933429db50dae838a3e20d596980 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 14 Jan 2025 16:55:30 -0500 Subject: [PATCH 3/5] feat(api): add csrf token to graphql playground --- api/src/unraid-api/graph/graph.module.ts | 70 ++++++++++++++++++------ 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 6f87b27835..e6cb1b6e4a 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -1,3 +1,10 @@ +import type { ApolloDriverConfig } from '@nestjs/apollo'; +import { ApolloDriver } from '@nestjs/apollo'; +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; + +import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; +import { NoUnusedVariablesRule, print } from 'graphql'; import { DateTimeResolver, JSONResolver, @@ -5,21 +12,40 @@ import { URLResolver, UUIDResolver, } from 'graphql-scalars'; -import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long'; -import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; -import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo'; -import { Module } from '@nestjs/common'; -import { GraphQLModule } from '@nestjs/graphql'; -import { ResolversModule } from './resolvers/resolvers.module'; + import { GRAPHQL_INTROSPECTION } from '@app/environment'; +import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long'; import { typeDefs } from '@app/graphql/schema/index'; -import { NoUnusedVariablesRule, print } from 'graphql'; +import { getters } from '@app/store'; +import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; + +import { ConnectResolver } from './connect/connect.resolver'; +import { ConnectService } from './connect/connect.service'; import { NetworkResolver } from './network/network.resolver'; +import { ResolversModule } from './resolvers/resolvers.module'; import { ServicesResolver } from './services/services.resolver'; import { SharesResolver } from './shares/shares.resolver'; -import { ConnectResolver } from './connect/connect.resolver'; -import { ConnectService } from './connect/connect.service'; -import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; + +/** The initial query displayed in the Apollo sandbox */ +const initialDocument = `query ExampleQuery { + notifications { + id + overview { + unread { + info + warning + alert + total + } + archive { + info + warning + alert + total + } + } + } +}`; @Module({ imports: [ @@ -34,7 +60,21 @@ import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; }), playground: false, plugins: GRAPHQL_INTROSPECTION - ? [ApolloServerPluginLandingPageLocalDefault(), idPrefixPlugin] + ? [ + ApolloServerPluginLandingPageLocalDefault({ + footer: false, + includeCookies: true, + document: initialDocument, + embed: { + initialState: { + sharedHeaders: { + 'x-csrf-token': getters.emhttp().var.csrfToken ?? 'no csrf token', + }, + }, + }, + }), + idPrefixPlugin, + ] : [idPrefixPlugin], subscriptions: { 'graphql-ws': { @@ -55,12 +95,6 @@ import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; // schema: schema }), ], - providers: [ - NetworkResolver, - ServicesResolver, - SharesResolver, - ConnectResolver, - ConnectService, - ], + providers: [NetworkResolver, ServicesResolver, SharesResolver, ConnectResolver, ConnectService], }) export class GraphModule {} From 8ea16fd323d47aa2b473407b2b452a063471deca Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 15 Jan 2025 13:51:21 -0500 Subject: [PATCH 4/5] Revert "refactor(api): load emhttp state during init" This reverts commit 9d09d0b2dec9f9ee4ea95568b96f51551fd9e45b. --- api/src/store/modules/emhttp.ts | 209 ++++++++++++++++---------------- 1 file changed, 104 insertions(+), 105 deletions(-) diff --git a/api/src/store/modules/emhttp.ts b/api/src/store/modules/emhttp.ts index 091a7db9dd..1d756a153d 100644 --- a/api/src/store/modules/emhttp.ts +++ b/api/src/store/modules/emhttp.ts @@ -1,33 +1,28 @@ -import { join } from 'path'; - -import type { PayloadAction } from '@reduxjs/toolkit'; -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { FileLoadStatus, StateFileKey, type StateFileToIniParserMap } from '@app/store/types'; +import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import merge from 'lodash/merge'; - -import type { RootState } from '@app/store'; -import type { StateFileToIniParserMap } from '@app/store/types'; +import { join } from 'path'; import { emhttpLogger } from '@app/core/log'; +import { parseConfig } from '@app/core/utils/misc/parse-config'; import { type Devices } from '@app/core/types/states/devices'; import { type Networks } from '@app/core/types/states/network'; -import { type NfsShares } from '@app/core/types/states/nfs'; import { type Nginx } from '@app/core/types/states/nginx'; import { type Shares } from '@app/core/types/states/share'; -import { type SmbShares } from '@app/core/types/states/smb'; import { type Users } from '@app/core/types/states/user'; +import { type NfsShares } from '@app/core/types/states/nfs'; +import { type SmbShares } from '@app/core/types/states/smb'; import { type Var } from '@app/core/types/states/var'; -import { parseConfig } from '@app/core/utils/misc/parse-config'; -import { type ArrayDisk } from '@app/graphql/generated/api/types'; import { parse as parseDevices } from '@app/store/state-parsers/devices'; import { parse as parseNetwork } from '@app/store/state-parsers/network'; -import { parse as parseNfsShares } from '@app/store/state-parsers/nfs'; import { parse as parseNginx } from '@app/store/state-parsers/nginx'; +import { parse as parseNfsShares } from '@app/store/state-parsers/nfs'; import { parse as parseShares } from '@app/store/state-parsers/shares'; import { parse as parseSlots } from '@app/store/state-parsers/slots'; import { parse as parseSmbShares } from '@app/store/state-parsers/smb'; import { parse as parseUsers } from '@app/store/state-parsers/users'; import { parse as parseVar } from '@app/store/state-parsers/var'; -import { FileLoadStatus, StateFileKey } from '@app/store/types'; -import { paths } from './paths'; +import type { RootState } from '@app/store'; +import { type ArrayDisk } from '@app/graphql/generated/api/types'; export type SliceState = { status: FileLoadStatus; @@ -42,6 +37,19 @@ export type SliceState = { nfsShares: NfsShares; }; +const initialState: SliceState = { + status: FileLoadStatus.UNLOADED, + var: {} as unknown as Var, + devices: [], + networks: [], + nginx: {} as unknown as Nginx, + shares: [], + disks: [], + users: [], + smbShares: [], + nfsShares: [], +}; + export const parsers: StateFileToIniParserMap = { [StateFileKey.var]: parseVar, [StateFileKey.devs]: parseDevices, @@ -54,11 +62,14 @@ export const parsers: StateFileToIniParserMap = { [StateFileKey.sec_nfs]: parseNfsShares, }; +const getParserFunction = ( + parser: StateFileKey +): StateFileToIniParserMap[StateFileKey] => parsers[parser]; -const getParserFunction = (parser: StateFileKey): StateFileToIniParserMap[StateFileKey] => - parsers[parser]; - -const parseState = | null>( +const parseState = < + T extends StateFileKey, + Q = ReturnType | null +>( statesDirectory: string, parser: T, defaultValue?: NonNullable @@ -89,60 +100,42 @@ const parseState = ; -} - -const initialState: SliceState = { - status: FileLoadStatus.UNLOADED, - ...loadState(paths.getInitialState().states) -}; - // @TODO Fix the type here Pick | null -export const loadSingleStateFile = createAsyncThunk( - 'emhttp/load-single-state-file', - async (stateFileKey, { getState }) => { - const path = getState().paths.states; - - const config = parseState(path, stateFileKey); - if (config) { - switch (stateFileKey) { - case StateFileKey.var: - return { var: config }; - case StateFileKey.devs: - return { devices: config }; - case StateFileKey.network: - return { networks: config }; - case StateFileKey.nginx: - return { nginx: config }; - case StateFileKey.shares: - return { shares: config }; - case StateFileKey.disks: - return { disks: config }; - case StateFileKey.users: - return { users: config }; - case StateFileKey.sec: - return { smbShares: config }; - case StateFileKey.sec_nfs: - return { nfsShares: config }; - default: - return null; - } - } else { - return null; +export const loadSingleStateFile = createAsyncThunk< + any, + StateFileKey, + { state: RootState } +>('emhttp/load-single-state-file', async (stateFileKey, { getState }) => { + const path = getState().paths.states; + + const config = parseState(path, stateFileKey); + if (config) { + switch (stateFileKey) { + case StateFileKey.var: + return { var: config }; + case StateFileKey.devs: + return { devices: config }; + case StateFileKey.network: + return { networks: config }; + case StateFileKey.nginx: + return { nginx: config }; + case StateFileKey.shares: + return { shares: config }; + case StateFileKey.disks: + return { disks: config }; + case StateFileKey.users: + return { users: config }; + case StateFileKey.sec: + return { smbShares: config }; + case StateFileKey.sec_nfs: + return { nfsShares: config }; + default: + return null; } + } else { + return null; } -); +}); /** * Load the emhttp states into the store. */ @@ -152,47 +145,53 @@ export const loadStateFiles = createAsyncThunk< { state: RootState } >('emhttp/load-state-file', async (_, { getState }) => { const path = getState().paths.states; - return loadState(path); + const state: Omit = { + var: parseState(path, StateFileKey.var, {} as Var), + devices: parseState(path, StateFileKey.devs, []), + networks: parseState(path, StateFileKey.network, []), + nginx: parseState(path, StateFileKey.nginx, {} as Nginx), + shares: parseState(path, StateFileKey.shares, []), + disks: parseState(path, StateFileKey.disks, []), + users: parseState(path, StateFileKey.users, []), + smbShares: parseState(path, StateFileKey.sec, []), + nfsShares: parseState(path, StateFileKey.sec_nfs, []), + }; + + return state; }); export const emhttp = createSlice({ - name: 'emhttp', - initialState, - reducers: { - updateEmhttpState( - state, - action: PayloadAction<{ - field: StateFileKey; - state: Partial<(typeof initialState)[keyof typeof initialState]>; - }> - ) { - const { field } = action.payload; - return merge(state, { [field]: action.payload.state }); - }, - }, - extraReducers(builder) { - builder.addCase(loadStateFiles.pending, (state) => { - state.status = FileLoadStatus.LOADING; - }); - - builder.addCase(loadStateFiles.fulfilled, (state, action) => { - merge(state, action.payload, { status: FileLoadStatus.LOADED }); - }); - - builder.addCase(loadStateFiles.rejected, (state, action) => { - merge(state, action.payload, { status: FileLoadStatus.FAILED_LOADING }); - }); - - builder.addCase(loadSingleStateFile.fulfilled, (state, action) => { - if (action.payload) { + name: 'emhttp', + initialState, + reducers: { + updateEmhttpState(state, action: PayloadAction<{ field: StateFileKey; state: Partial }>) { + const { field } = action.payload; + return merge(state, { [field]: action.payload.state }); + }, + }, + extraReducers(builder) { + builder.addCase(loadStateFiles.pending, (state) => { + state.status = FileLoadStatus.LOADING; + }); + + builder.addCase(loadStateFiles.fulfilled, (state, action) => { + merge(state, action.payload, { status: FileLoadStatus.LOADED }); + }); + + builder.addCase(loadStateFiles.rejected, (state, action) => { + merge(state, action.payload, { status: FileLoadStatus.FAILED_LOADING }); + }); + + builder.addCase(loadSingleStateFile.fulfilled, (state, action) => { + if (action.payload) { // const changedKey = Object.keys(action.payload)[0] // emhttpLogger.debug('Key', changedKey, 'Difference in changes', getDiff(action.payload, { [changedKey]: state[changedKey] } )) - merge(state, action.payload); - } else { - emhttpLogger.warn('Invalid payload returned from loadSingleStateFile()'); - } - }); - }, + merge(state, action.payload); + } else { + emhttpLogger.warn('Invalid payload returned from loadSingleStateFile()'); + } + }); + }, }); export const { updateEmhttpState } = emhttp.actions; From b73e76cb53c2f147e435bae1eb83fa681ac8240f Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 15 Jan 2025 16:22:13 -0500 Subject: [PATCH 5/5] feat(api): use custom apollo plugin to render sandbox --- api/src/unraid-api/graph/graph.module.ts | 42 +---------- api/src/unraid-api/graph/sandbox-plugin.ts | 82 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 40 deletions(-) create mode 100644 api/src/unraid-api/graph/sandbox-plugin.ts diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index e6cb1b6e4a..6c63d28d59 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -3,7 +3,6 @@ import { ApolloDriver } from '@nestjs/apollo'; import { Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; -import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; import { NoUnusedVariablesRule, print } from 'graphql'; import { DateTimeResolver, @@ -16,37 +15,16 @@ import { import { GRAPHQL_INTROSPECTION } from '@app/environment'; import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long'; import { typeDefs } from '@app/graphql/schema/index'; -import { getters } from '@app/store'; import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; import { ConnectResolver } from './connect/connect.resolver'; import { ConnectService } from './connect/connect.service'; import { NetworkResolver } from './network/network.resolver'; import { ResolversModule } from './resolvers/resolvers.module'; +import { sandboxPlugin } from './sandbox-plugin'; import { ServicesResolver } from './services/services.resolver'; import { SharesResolver } from './shares/shares.resolver'; -/** The initial query displayed in the Apollo sandbox */ -const initialDocument = `query ExampleQuery { - notifications { - id - overview { - unread { - info - warning - alert - total - } - archive { - info - warning - alert - total - } - } - } -}`; - @Module({ imports: [ ResolversModule, @@ -59,23 +37,7 @@ const initialDocument = `query ExampleQuery { extra, }), playground: false, - plugins: GRAPHQL_INTROSPECTION - ? [ - ApolloServerPluginLandingPageLocalDefault({ - footer: false, - includeCookies: true, - document: initialDocument, - embed: { - initialState: { - sharedHeaders: { - 'x-csrf-token': getters.emhttp().var.csrfToken ?? 'no csrf token', - }, - }, - }, - }), - idPrefixPlugin, - ] - : [idPrefixPlugin], + plugins: GRAPHQL_INTROSPECTION ? [sandboxPlugin, idPrefixPlugin] : [idPrefixPlugin], subscriptions: { 'graphql-ws': { path: '/graphql', diff --git a/api/src/unraid-api/graph/sandbox-plugin.ts b/api/src/unraid-api/graph/sandbox-plugin.ts new file mode 100644 index 0000000000..7b2817554d --- /dev/null +++ b/api/src/unraid-api/graph/sandbox-plugin.ts @@ -0,0 +1,82 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +import type { ApolloServerPlugin, GraphQLServerContext, GraphQLServerListener } from '@apollo/server'; + +/** The initial query displayed in the Apollo sandbox */ +const initialDocument = `query ExampleQuery { + notifications { + id + overview { + unread { + info + warning + alert + total + } + archive { + info + warning + alert + total + } + } + } + }`; + +/** helper for raising precondition failure errors during an http request. */ +const preconditionFailed = (preconditionName: string) => { + throw new HttpException(`Precondition failed: ${preconditionName} `, HttpStatus.PRECONDITION_FAILED); +}; + +/** + * Renders the sandbox page for the GraphQL server with Apollo Server landing page configuration. + * + * @param service - The GraphQL server context object + * @returns Promise that resolves to an Apollo `LandingPage`, or throws a precondition failed error + * @throws {Error} When downstream plugin components from apollo are unavailable. This should never happen. + * + * @remarks + * This function configures and renders the Apollo Server landing page with: + * - Disabled footer + * - Enabled cookies + * - Initial document state + * - Shared headers containing CSRF token + */ +async function renderSandboxPage(service: GraphQLServerContext) { + const { getters } = await import('@app/store'); + const { ApolloServerPluginLandingPageLocalDefault } = await import( + '@apollo/server/plugin/landingPage/default' + ); + const plugin = ApolloServerPluginLandingPageLocalDefault({ + footer: false, + includeCookies: true, + document: initialDocument, + embed: { + initialState: { + sharedHeaders: { + 'x-csrf-token': getters.emhttp().var.csrfToken, + }, + }, + }, + }); + if (!plugin.serverWillStart) return preconditionFailed('serverWillStart'); + const serverListener = await plugin.serverWillStart(service); + + if (!serverListener) return preconditionFailed('serverListener'); + if (!serverListener.renderLandingPage) return preconditionFailed('renderLandingPage'); + return serverListener.renderLandingPage(); +} + +/** + * Apollo plugin to render the GraphQL Sandbox page on-demand based on current server state. + * + * Usually, the `ApolloServerPluginLandingPageLocalDefault` plugin configures its + * parameters once, during server startup. This plugin defers the configuration + * and rendering to request-time instead of server startup. + */ +export const sandboxPlugin: ApolloServerPlugin = { + serverWillStart: async (service) => + ({ + renderLandingPage: () => renderSandboxPage(service), + }) satisfies GraphQLServerListener, +};