From 682cde81092fcb3a55de7f24035be4f2f64abfb3 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 18 Oct 2022 08:49:25 +0200 Subject: [PATCH] Add self-hosting options to GraphQL Hive client (#499) --- .changeset/fuzzy-plants-crash.md | 5 + packages/libraries/client/README.md | 29 ++++++ packages/libraries/client/src/client.ts | 8 +- .../client/src/internal/operations-store.ts | 3 +- .../client/src/internal/reporting.ts | 4 +- .../libraries/client/src/internal/types.ts | 29 ++++++ .../libraries/client/src/internal/usage.ts | 3 +- packages/libraries/client/tests/info.spec.ts | 73 ++++++++++++++ .../libraries/client/tests/reporting.spec.ts | 81 +++++++++++++++ packages/libraries/client/tests/usage.spec.ts | 99 +++++++++++++++++++ 10 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 .changeset/fuzzy-plants-crash.md diff --git a/.changeset/fuzzy-plants-crash.md b/.changeset/fuzzy-plants-crash.md new file mode 100644 index 000000000..c4b36f82c --- /dev/null +++ b/.changeset/fuzzy-plants-crash.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/client': minor +--- + +Add Self-Hosting options diff --git a/packages/libraries/client/README.md b/packages/libraries/client/README.md index 509cc6f6c..de4029c97 100644 --- a/packages/libraries/client/README.md +++ b/packages/libraries/client/README.md @@ -304,3 +304,32 @@ const server = new ApolloServer({ ] }) ``` + +## Self-Hosting + +To align the client with your own instance of GraphQL Hive, you should use `selfHosting` options in the client configuration. + +The example is based on GraphQL Yoga, but the same configuration applies to Apollo Server and others. + +```ts +import { createYoga } from '@graphql-yoga/node' +import { useHive } from '@graphql-hive/client' + +const server = createYoga({ + plugins: [ + useHive({ + enabled: true, + token: 'YOUR-TOKEN', + selfHosting: { + graphqlEndpoint: 'https://your-own-graphql-hive.com/graphql', + applicationUrl: 'https://your-own-graphql-hive.com', + usageEndpoint: 'https://your-own-graphql-hive.com/usage' // optional + } + }) + ] +}) + +server.start() +``` + +> The `selfHosting` options take precedence over the deprecated `options.hosting.endpoint` and `options.usage.endpoint`. diff --git a/packages/libraries/client/src/client.ts b/packages/libraries/client/src/client.ts index b35f31c90..79747621d 100644 --- a/packages/libraries/client/src/client.ts +++ b/packages/libraries/client/src/client.ts @@ -42,10 +42,15 @@ export function createHive(options: HivePluginOptions): HiveClient { try { let endpoint = 'https://app.graphql-hive.com/graphql'; + // Look for the reporting.endpoint for the legacy reason. if (options.reporting && options.reporting.endpoint) { endpoint = options.reporting.endpoint; } + if (options.selfHosting?.graphqlEndpoint) { + endpoint = options.selfHosting.graphqlEndpoint; + } + const query = /* GraphQL */ ` query myTokenInfo { tokenInfo { @@ -104,7 +109,8 @@ export function createHive(options: HivePluginOptions): HiveClient { const { organization, project, target, canReportSchema, canCollectUsage, canReadOperations } = tokenInfo; const print = createPrinter([tokenInfo.token.name, organization.name, project.name, target.name]); - const organizationUrl = `https://app.graphql-hive.com/${organization.cleanId}`; + const appUrl = options.selfHosting?.applicationUrl?.replace(/\/$/, '') ?? 'https://app.graphql-hive.com'; + const organizationUrl = `${appUrl}/${organization.cleanId}`; const projectUrl = `${organizationUrl}/${project.cleanId}`; const targetUrl = `${projectUrl}/${target.cleanId}`; diff --git a/packages/libraries/client/src/internal/operations-store.ts b/packages/libraries/client/src/internal/operations-store.ts index e951feb0c..b22e70d97 100644 --- a/packages/libraries/client/src/internal/operations-store.ts +++ b/packages/libraries/client/src/internal/operations-store.ts @@ -12,6 +12,7 @@ export interface OperationsStore { export function createOperationsStore(pluginOptions: HivePluginOptions): OperationsStore { const operationsStoreOptions = pluginOptions.operationsStore; + const selfHostingOptions = pluginOptions.selfHosting; const token = pluginOptions.token; if (!operationsStoreOptions || pluginOptions.enabled === false) { @@ -39,7 +40,7 @@ export function createOperationsStore(pluginOptions: HivePluginOptions): Operati const load: OperationsStore['load'] = async () => { const response = await axios.post( - operationsStoreOptions.endpoint ?? 'https://app.graphql-hive.com/graphql', + selfHostingOptions?.graphqlEndpoint ?? operationsStoreOptions.endpoint ?? 'https://app.graphql-hive.com/graphql', { query, operationName: 'loadStoredOperations', diff --git a/packages/libraries/client/src/internal/reporting.ts b/packages/libraries/client/src/internal/reporting.ts index 12cef71fd..7f9ba3d91 100644 --- a/packages/libraries/client/src/internal/reporting.ts +++ b/packages/libraries/client/src/internal/reporting.ts @@ -20,6 +20,7 @@ export function createReporting(pluginOptions: HivePluginOptions): SchemaReporte } const token = pluginOptions.token; + const selfHostingOptions = pluginOptions.selfHosting; const reportingOptions = pluginOptions.reporting; const logger = pluginOptions.agent?.logger ?? console; @@ -40,7 +41,8 @@ export function createReporting(pluginOptions: HivePluginOptions): SchemaReporte { logger, ...(pluginOptions.agent ?? {}), - endpoint: reportingOptions.endpoint ?? 'https://app.graphql-hive.com/graphql', + endpoint: + selfHostingOptions?.graphqlEndpoint ?? reportingOptions.endpoint ?? 'https://app.graphql-hive.com/graphql', token: token, enabled: pluginOptions.enabled, debug: pluginOptions.debug, diff --git a/packages/libraries/client/src/internal/types.ts b/packages/libraries/client/src/internal/types.ts index 9ff8e9cbf..f8ddd28a6 100644 --- a/packages/libraries/client/src/internal/types.ts +++ b/packages/libraries/client/src/internal/types.ts @@ -28,6 +28,8 @@ export interface HiveUsagePluginOptions { /** * Custom endpoint to collect schema usage * + * @deprecated use `options.selfHosted.usageEndpoint` instead + * * Points to Hive by default */ endpoint?: string; @@ -77,6 +79,8 @@ export interface HiveReportingPluginOptions { /** * Custom endpoint to collect schema reports * + * @deprecated use `options.selfHosted.usageEndpoint` instead + * * Points to Hive by default */ endpoint?: string; @@ -107,6 +111,27 @@ export interface HiveOperationsStorePluginOptions { endpoint?: string; } +export interface HiveSelfHostingOptions { + /** + * Point to your own instance of GraphQL Hive API + * + * Used by schema reporting and token info. + */ + graphqlEndpoint: string; + /** + * Address of your own GraphQL Hive application + * + * Used by token info to generate a link to the organization, project and target. + */ + applicationUrl: string; + /** + * Point to your own instance of GraphQL Hive Usage API + * + * Used by usage reporting + */ + usageEndpoint?: string; +} + export interface HivePluginOptions { /** * Enable/Disable Hive @@ -124,6 +149,10 @@ export interface HivePluginOptions { * Access Token */ token: string; + /** + * Use when self-hosting GraphQL Hive + */ + selfHosting?: HiveSelfHostingOptions; agent?: Omit; /** * Collects schema usage based on operations diff --git a/packages/libraries/client/src/internal/usage.ts b/packages/libraries/client/src/internal/usage.ts index b46dec3f2..7ea459aff 100644 --- a/packages/libraries/client/src/internal/usage.ts +++ b/packages/libraries/client/src/internal/usage.ts @@ -52,6 +52,7 @@ export function createUsage(pluginOptions: HivePluginOptions): UsageCollector { operations: [], }; const options = typeof pluginOptions.usage === 'boolean' ? ({} as HiveUsagePluginOptions) : pluginOptions.usage; + const selfHostingOptions = pluginOptions.selfHosting; const logger = pluginOptions.agent?.logger ?? console; const collector = memo(createCollector, arg => arg.schema); const excludeSet = new Set(options.exclude ?? []); @@ -61,7 +62,7 @@ export function createUsage(pluginOptions: HivePluginOptions): UsageCollector { ...(pluginOptions.agent ?? { maxSize: 1500, }), - endpoint: options.endpoint ?? 'https://app.graphql-hive.com/usage', + endpoint: selfHostingOptions?.usageEndpoint ?? options.endpoint ?? 'https://app.graphql-hive.com/usage', token: pluginOptions.token, enabled: pluginOptions.enabled, debug: pluginOptions.debug, diff --git a/packages/libraries/client/tests/info.spec.ts b/packages/libraries/client/tests/info.spec.ts index 3f2f2a536..7d788f41b 100644 --- a/packages/libraries/client/tests/info.spec.ts +++ b/packages/libraries/client/tests/info.spec.ts @@ -1,4 +1,6 @@ import { createHive } from '../src/client'; +// eslint-disable-next-line import/no-extraneous-dependencies +import nock from 'nock'; test('should not leak the exception', async () => { const logger = { @@ -28,3 +30,74 @@ test('should not leak the exception', async () => { expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(`[hive][info] Error`)); expect(result).toBe('OK'); }); + +test('should use selfHosting.graphqlEndpoint if provided', async () => { + const logger = { + error: jest.fn(), + info: jest.fn(), + }; + + nock('http://localhost') + .post('/graphql') + .once() + .reply(200, { + data: { + tokenInfo: { + __typename: 'TokenInfo', + token: { + name: 'My Token', + }, + organization: { + name: 'Org', + cleanId: 'org-id', + }, + project: { + name: 'Project', + type: 'SINGLE', + cleanId: 'project-id', + }, + target: { + name: 'Target', + cleanId: 'target-id', + }, + canReportSchema: true, + canCollectUsage: true, + canReadOperations: false, + }, + }, + }); + + const hive = createHive({ + enabled: true, + debug: true, + agent: { + logger, + }, + token: 'Token', + selfHosting: { + graphqlEndpoint: 'http://localhost/graphql', + applicationUrl: 'http://localhost/', + }, + }); + + const result = await hive + .info() + .then(() => 'OK') + .catch(() => 'ERROR'); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(`[hive][info] Token details`)); + expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/Token name: \s+ My Token/)); + expect(logger.info).toHaveBeenCalledWith( + expect.stringMatching(/Organization: \s+ Org \s+ http:\/\/localhost\/org-id/) + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringMatching(/Project: \s+ Project \s+ http:\/\/localhost\/org-id\/project-id/) + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringMatching(/Target: \s+ Target \s+ http:\/\/localhost\/org-id\/project-id\/target-id/) + ); + expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/Can report schema\? \s+ Yes/)); + expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/Can collect usage\? \s+ Yes/)); + expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/Can read operations\? \s+ No/)); + expect(result).toBe('OK'); +}); diff --git a/packages/libraries/client/tests/reporting.spec.ts b/packages/libraries/client/tests/reporting.spec.ts index 47aa8c3bf..2c30a1ddc 100644 --- a/packages/libraries/client/tests/reporting.spec.ts +++ b/packages/libraries/client/tests/reporting.spec.ts @@ -71,6 +71,87 @@ test('should send data to Hive', async () => { const serviceUrl = 'https://api.com'; const serviceName = 'my-api'; + let body: any = {}; + const http = nock('http://localhost') + .post('/200') + .matchHeader('Authorization', `Bearer ${token}`) + .matchHeader('Content-Type', headers['Content-Type']) + .matchHeader('graphql-client-name', headers['graphql-client-name']) + .matchHeader('graphql-client-version', headers['graphql-client-version']) + .once() + .reply((_, _body) => { + body = _body; + return [ + 200, + { + data: { + schemaPublish: { + __typename: 'SchemaPublishSuccess', + initial: false, + valid: true, + }, + }, + }, + ]; + }); + + const hive = createHive({ + enabled: true, + debug: true, + agent: { + timeout: 500, + maxRetries: 1, + logger, + }, + token, + selfHosting: { + graphqlEndpoint: 'http://localhost/200', + applicationUrl: 'http://localhost', + }, + reporting: { + author, + commit, + serviceUrl, + serviceName, + }, + }); + + hive.reportSchema({ + schema: buildSchema(/* GraphQL */ ` + type Query { + foo: String + } + `), + }); + + await waitFor(2000); + await hive.dispose(); + http.done(); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('[hive][reporting] Sending (queue 1) (attempt 1)'); + expect(logger.info).toHaveBeenCalledWith(`[hive][reporting] Sent!`); + + expect(body.variables.input.sdl).toBe(`type Query{foo:String}`); + expect(body.variables.input.author).toBe(author); + expect(body.variables.input.commit).toBe(commit); + expect(body.variables.input.service).toBe(serviceName); + expect(body.variables.input.url).toBe(serviceUrl); + expect(body.variables.input.force).toBe(true); +}); + +test('should send data to Hive (deprecated endpoint)', async () => { + const logger = { + error: jest.fn(), + info: jest.fn(), + }; + + const author = 'Test'; + const commit = 'Commit'; + const token = 'Token'; + const serviceUrl = 'https://api.com'; + const serviceName = 'my-api'; + let body: any = {}; const http = nock('http://localhost') .post('/200') diff --git a/packages/libraries/client/tests/usage.spec.ts b/packages/libraries/client/tests/usage.spec.ts index 4c2bee770..dcdafb3ef 100644 --- a/packages/libraries/client/tests/usage.spec.ts +++ b/packages/libraries/client/tests/usage.spec.ts @@ -102,6 +102,105 @@ test('should send data to Hive', async () => { const token = 'Token'; + let report: Report = { + size: 0, + map: {}, + operations: [], + }; + const http = nock('http://localhost') + .post('/200') + .matchHeader('Authorization', `Bearer ${token}`) + .matchHeader('Content-Type', headers['Content-Type']) + .matchHeader('graphql-client-name', headers['graphql-client-name']) + .matchHeader('graphql-client-version', headers['graphql-client-version']) + .once() + .reply((_, _body) => { + report = _body as any; + return [200]; + }); + + const hive = createHive({ + enabled: true, + debug: true, + agent: { + timeout: 500, + maxRetries: 0, + logger, + }, + token, + selfHosting: { + graphqlEndpoint: 'http://localhost/graphql', + applicationUrl: 'http://localhost/', + usageEndpoint: 'http://localhost/200', + }, + usage: true, + }); + + const collect = hive.collectUsage({ + schema, + document: op, + operationName: 'deleteProject', + }); + + await waitFor(2000); + collect({}); + await hive.dispose(); + await waitFor(1000); + http.done(); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 1) (attempt 1)`); + expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`); + + // Map + expect(report.size).toEqual(1); + expect(Object.keys(report.map)).toHaveLength(1); + + const key = Object.keys(report.map)[0]; + const record = report.map[key]; + + // operation + expect(record.operation).toMatch('mutation deleteProject'); + expect(record.operationName).toMatch('deleteProject'); + // fields + expect(record.fields).toHaveLength(13); + expect(record.fields).toContainEqual('Mutation.deleteProject'); + expect(record.fields).toContainEqual('Mutation.deleteProject.selector'); + expect(record.fields).toContainEqual('DeleteProjectPayload.selector'); + expect(record.fields).toContainEqual('ProjectSelector.organization'); + expect(record.fields).toContainEqual('ProjectSelector.project'); + expect(record.fields).toContainEqual('DeleteProjectPayload.deletedProject'); + expect(record.fields).toContainEqual('Project.id'); + expect(record.fields).toContainEqual('Project.cleanId'); + expect(record.fields).toContainEqual('Project.name'); + expect(record.fields).toContainEqual('Project.type'); + expect(record.fields).toContainEqual('ProjectSelectorInput.organization'); + expect(record.fields).toContainEqual('ID'); + expect(record.fields).toContainEqual('ProjectSelectorInput.project'); + + // Operations + const operations = report.operations; + expect(operations).toHaveLength(1); // one operation + const operation = operations[0]; + + expect(operation.operationMapKey).toEqual(key); + expect(operation.timestamp).toEqual(expect.any(Number)); + // execution + expect(operation.execution.duration).toBeGreaterThanOrEqual(2000 * 1_000_000); // >=2000ms in microseconds + expect(operation.execution.duration).toBeLessThan(3000 * 1_000_000); // <3000ms + expect(operation.execution.errorsTotal).toBe(0); + expect(operation.execution.errors).toHaveLength(0); + expect(operation.execution.ok).toBe(true); +}); + +test('should send data to Hive (deprecated endpoint)', async () => { + const logger = { + error: jest.fn(), + info: jest.fn(), + }; + + const token = 'Token'; + let report: Report = { size: 0, map: {},