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
This commit is contained in:
Pujit Mehrotra
2025-01-16 10:17:09 -05:00
committed by GitHub
parent 4264557789
commit 5dd36d1836
3 changed files with 106 additions and 22 deletions

View File

@@ -45,7 +45,13 @@ eval "$rsync_command"
exit_code=$? exit_code=$?
# Run unraid-api restart on remote host # 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 # Play built-in sound based on the operating system
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then

View File

@@ -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 { import {
DateTimeResolver, DateTimeResolver,
JSONResolver, JSONResolver,
@@ -5,21 +11,19 @@ import {
URLResolver, URLResolver,
UUIDResolver, UUIDResolver,
} from 'graphql-scalars'; } 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 { GRAPHQL_INTROSPECTION } from '@app/environment';
import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long';
import { typeDefs } from '@app/graphql/schema/index'; import { typeDefs } from '@app/graphql/schema/index';
import { NoUnusedVariablesRule, print } from 'graphql'; import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin';
import { NetworkResolver } from './network/network.resolver';
import { ServicesResolver } from './services/services.resolver';
import { SharesResolver } from './shares/shares.resolver';
import { ConnectResolver } from './connect/connect.resolver'; import { ConnectResolver } from './connect/connect.resolver';
import { ConnectService } from './connect/connect.service'; 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({ @Module({
imports: [ imports: [
@@ -33,9 +37,7 @@ import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin';
extra, extra,
}), }),
playground: false, playground: false,
plugins: GRAPHQL_INTROSPECTION plugins: GRAPHQL_INTROSPECTION ? [sandboxPlugin, idPrefixPlugin] : [idPrefixPlugin],
? [ApolloServerPluginLandingPageLocalDefault(), idPrefixPlugin]
: [idPrefixPlugin],
subscriptions: { subscriptions: {
'graphql-ws': { 'graphql-ws': {
path: '/graphql', path: '/graphql',
@@ -55,12 +57,6 @@ import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin';
// schema: schema // schema: schema
}), }),
], ],
providers: [ providers: [NetworkResolver, ServicesResolver, SharesResolver, ConnectResolver, ConnectService],
NetworkResolver,
ServicesResolver,
SharesResolver,
ConnectResolver,
ConnectService,
],
}) })
export class GraphModule {} export class GraphModule {}

View File

@@ -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,
};