Add self-hosting options to GraphQL Hive client (#499)

This commit is contained in:
Kamil Kisiela 2022-10-18 08:49:25 +02:00 committed by GitHub
parent 57ed1a8edb
commit 682cde8109
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 330 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
'@graphql-hive/client': minor
---
Add Self-Hosting options

View file

@ -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`.

View file

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

View file

@ -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',

View file

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

View file

@ -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<AgentOptions, 'endpoint' | 'token' | 'enabled' | 'debug'>;
/**
* Collects schema usage based on operations

View file

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

View file

@ -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');
});

View file

@ -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')

View file

@ -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: {},