mirror of
https://github.com/graphql-hive/console
synced 2026-05-23 00:58:36 +00:00
Add self-hosting options to GraphQL Hive client (#499)
This commit is contained in:
parent
57ed1a8edb
commit
682cde8109
10 changed files with 330 additions and 4 deletions
5
.changeset/fuzzy-plants-crash.md
Normal file
5
.changeset/fuzzy-plants-crash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@graphql-hive/client': minor
|
||||
---
|
||||
|
||||
Add Self-Hosting options
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
Loading…
Reference in a new issue