From 5dd36d18361b44d133b4976f536d8111537c72d3 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 16 Jan 2025 10:17:09 -0500 Subject: [PATCH] feat(api): graphql sandbox on unraid servers (#1047) Enables a sandbox at /graphql for developers wanting to interact with the unraid api. * chore(api): enable introspection by default in deploy-dev script * refactor(api): load emhttp state during init so emhttp settings are always available, even at module load time. * feat(api): add csrf token to graphql playground * Revert "refactor(api): load emhttp state during init" * feat(api): use custom apollo plugin to render sandbox --- api/scripts/deploy-dev.sh | 8 ++- api/src/unraid-api/graph/graph.module.ts | 38 +++++----- api/src/unraid-api/graph/sandbox-plugin.ts | 82 ++++++++++++++++++++++ 3 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 api/src/unraid-api/graph/sandbox-plugin.ts diff --git a/api/scripts/deploy-dev.sh b/api/scripts/deploy-dev.sh index 416d90ed6..08fcb8a49 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 diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 6f87b2783..6c63d28d5 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -1,3 +1,9 @@ +import type { ApolloDriverConfig } from '@nestjs/apollo'; +import { ApolloDriver } from '@nestjs/apollo'; +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; + +import { NoUnusedVariablesRule, print } from 'graphql'; import { DateTimeResolver, JSONResolver, @@ -5,21 +11,19 @@ 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 { NetworkResolver } from './network/network.resolver'; -import { ServicesResolver } from './services/services.resolver'; -import { SharesResolver } from './shares/shares.resolver'; +import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; + import { ConnectResolver } from './connect/connect.resolver'; import { ConnectService } from './connect/connect.service'; -import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; +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'; @Module({ imports: [ @@ -33,9 +37,7 @@ import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; extra, }), playground: false, - plugins: GRAPHQL_INTROSPECTION - ? [ApolloServerPluginLandingPageLocalDefault(), idPrefixPlugin] - : [idPrefixPlugin], + plugins: GRAPHQL_INTROSPECTION ? [sandboxPlugin, idPrefixPlugin] : [idPrefixPlugin], subscriptions: { 'graphql-ws': { path: '/graphql', @@ -55,12 +57,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 {} 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 000000000..7b2817554 --- /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, +};