mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Show error messages in the client when publishing the schema (#272)
Closes #267
This commit is contained in:
parent
617192779b
commit
ef18a38cca
8 changed files with 282 additions and 39 deletions
5
.changeset/many-wasps-count.md
Normal file
5
.changeset/many-wasps-count.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@graphql-hive/client': patch
|
||||
---
|
||||
|
||||
Show error messages when publishing the schema
|
||||
|
|
@ -15,3 +15,4 @@ __generated__/
|
|||
|
||||
# test fixtures
|
||||
integration-tests/fixtures/init-invalid-schema.graphql
|
||||
/target
|
||||
|
|
|
|||
10
codegen.yml
10
codegen.yml
|
|
@ -118,6 +118,16 @@ generates:
|
|||
- typescript-operations
|
||||
- typescript-graphql-request
|
||||
|
||||
# Client
|
||||
packages/libraries/client/src/__generated__/types.ts:
|
||||
documents: ./packages/libraries/client/src/**/*.ts
|
||||
config:
|
||||
flattenGeneratedTypes: true
|
||||
onlyOperationTypes: true
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
|
||||
# Integration tests
|
||||
./integration-tests/testkit/gql:
|
||||
documents: ./integration-tests/**/*.ts
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default {
|
|||
},
|
||||
restoreMocks: true,
|
||||
reporters: ['default'],
|
||||
modulePathIgnorePatterns: ['dist', 'integration-tests', 'tmp'],
|
||||
modulePathIgnorePatterns: ['dist', 'integration-tests', 'tmp', 'target'],
|
||||
moduleNameMapper: {
|
||||
...pathsToModuleNameMapper(tsconfig.compilerOptions.paths, {
|
||||
prefix: `${ROOT_DIR}/`,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export function createHive(options: HivePluginOptions): HiveClient {
|
|||
const operationsStore = createOperationsStore(options);
|
||||
|
||||
function reportSchema({ schema }: { schema: GraphQLSchema }) {
|
||||
schemaReporter.report({ schema });
|
||||
void schemaReporter.report({ schema });
|
||||
}
|
||||
|
||||
function collectUsage(args: ExecutionArgs) {
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ export interface AgentOptions {
|
|||
* 200 by default
|
||||
*/
|
||||
minTimeout?: number;
|
||||
/**
|
||||
* Send report after each GraphQL operation
|
||||
*/
|
||||
sendImmediately?: boolean;
|
||||
/**
|
||||
* Send reports in interval (defaults to 10_000ms)
|
||||
*/
|
||||
|
|
@ -48,7 +44,7 @@ export interface AgentOptions {
|
|||
logger?: Logger;
|
||||
}
|
||||
|
||||
export function createAgent<T>(
|
||||
export function createAgent<TEvent, TResult = void>(
|
||||
pluginOptions: AgentOptions,
|
||||
{
|
||||
prefix,
|
||||
|
|
@ -59,7 +55,7 @@ export function createAgent<T>(
|
|||
prefix: string;
|
||||
data: {
|
||||
clear(): void;
|
||||
set(data: T): void;
|
||||
set(data: TEvent): void;
|
||||
size(): number;
|
||||
};
|
||||
body(): Buffer | string | Promise<string | Buffer>;
|
||||
|
|
@ -72,7 +68,6 @@ export function createAgent<T>(
|
|||
enabled: true,
|
||||
minTimeout: 200,
|
||||
maxRetries: 3,
|
||||
sendImmediately: false,
|
||||
sendInterval: 10_000,
|
||||
maxSize: 25,
|
||||
logger: console,
|
||||
|
|
@ -92,33 +87,46 @@ export function createAgent<T>(
|
|||
timeoutID = setTimeout(send, options.sendInterval);
|
||||
}
|
||||
|
||||
if (!options.sendImmediately) {
|
||||
schedule();
|
||||
}
|
||||
|
||||
function debugLog(msg: string) {
|
||||
if (options.debug) {
|
||||
options.logger.info(`[hive][${prefix}]${enabled ? '' : '[DISABLED]'} ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function capture(event: T) {
|
||||
let scheduled = false;
|
||||
|
||||
function capture(event: TEvent) {
|
||||
// Calling capture starts the schedule
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
schedule();
|
||||
}
|
||||
|
||||
data.set(event);
|
||||
|
||||
if (options.sendImmediately || data.size() >= options.maxSize) {
|
||||
if (data.size() >= options.maxSize) {
|
||||
debugLog('Sending immediately');
|
||||
setImmediate(() => send({ runOnce: true }));
|
||||
setImmediate(() => send({ runOnce: true, throwOnError: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function send(sendOptions?: { runOnce?: boolean }): Promise<void> {
|
||||
function sendImmediately(event: TEvent): Promise<TResult | null> {
|
||||
data.set(event);
|
||||
|
||||
debugLog('Sending immediately');
|
||||
return send({ runOnce: true, throwOnError: true });
|
||||
}
|
||||
|
||||
async function send<T>(sendOptions: { runOnce?: boolean; throwOnError: true }): Promise<T | null | never>;
|
||||
async function send<T>(sendOptions: { runOnce?: boolean; throwOnError: false }): Promise<T | null>;
|
||||
async function send<T>(sendOptions?: { runOnce?: boolean; throwOnError: boolean }): Promise<T | null | never> {
|
||||
const runOnce = sendOptions?.runOnce ?? false;
|
||||
|
||||
if (!data.size()) {
|
||||
if (!runOnce) {
|
||||
schedule();
|
||||
}
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -127,18 +135,23 @@ export function createAgent<T>(
|
|||
|
||||
data.clear();
|
||||
|
||||
const sendReport: retry.RetryFunction<any> = async (_bail, attempt) => {
|
||||
const sendReport: retry.RetryFunction<{
|
||||
status: number;
|
||||
data: T | null;
|
||||
}> = async (_bail, attempt) => {
|
||||
debugLog(`Sending (queue ${dataToSend}) (attempt ${attempt})`);
|
||||
|
||||
if (!enabled) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
status: 200,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios
|
||||
.post(options.endpoint, buffer, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
Authorization: `Bearer ${options.token}`,
|
||||
'User-Agent': `${options.name}@${version}`,
|
||||
|
|
@ -166,17 +179,29 @@ export function createAgent<T>(
|
|||
factor: 2,
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`[hive][${prefix}] Failed to send data (HTTP status ${response.status}): ${response.data}`);
|
||||
}
|
||||
|
||||
debugLog(`Sent!`);
|
||||
} catch (error: any) {
|
||||
options.logger.error(`[hive][${prefix}] Failed to send data: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!runOnce) {
|
||||
schedule();
|
||||
if (!runOnce) {
|
||||
schedule();
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (!runOnce) {
|
||||
schedule();
|
||||
}
|
||||
|
||||
if (sendOptions?.throwOnError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
options.logger.error(`[hive][${prefix}] Failed to send data: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,11 +213,13 @@ export function createAgent<T>(
|
|||
|
||||
await send({
|
||||
runOnce: true,
|
||||
throwOnError: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
capture,
|
||||
sendImmediately,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { GraphQLSchema, stripIgnoredCharacters, print, Kind } from 'graphql';
|
||||
import { GraphQLSchema, stripIgnoredCharacters, print, Kind, ExecutionResult } from 'graphql';
|
||||
import { getDocumentNodeFromSchema } from '@graphql-tools/utils';
|
||||
import { createAgent } from './agent';
|
||||
import { version } from '../version';
|
||||
import type { HivePluginOptions } from './types';
|
||||
import type { SchemaPublishMutation } from '../__generated__/types';
|
||||
import { logIf } from './utils';
|
||||
|
||||
export interface SchemaReporter {
|
||||
|
|
@ -13,7 +14,7 @@ export interface SchemaReporter {
|
|||
export function createReporting(pluginOptions: HivePluginOptions): SchemaReporter {
|
||||
if (!pluginOptions.reporting || pluginOptions.enabled === false) {
|
||||
return {
|
||||
report() {},
|
||||
async report() {},
|
||||
async dispose() {},
|
||||
};
|
||||
}
|
||||
|
|
@ -35,7 +36,7 @@ export function createReporting(pluginOptions: HivePluginOptions): SchemaReporte
|
|||
logIf(typeof token !== 'string' || token.length === 0, '[hive][reporting] token is missing', logger.error);
|
||||
|
||||
let currentSchema: GraphQLSchema | null = null;
|
||||
const agent = createAgent<GraphQLSchema>(
|
||||
const agent = createAgent<GraphQLSchema, ExecutionResult<SchemaPublishMutation>>(
|
||||
{
|
||||
logger,
|
||||
...(pluginOptions.agent ?? {}),
|
||||
|
|
@ -43,7 +44,6 @@ export function createReporting(pluginOptions: HivePluginOptions): SchemaReporte
|
|||
token: token,
|
||||
enabled: pluginOptions.enabled,
|
||||
debug: pluginOptions.debug,
|
||||
sendImmediately: true,
|
||||
},
|
||||
{
|
||||
prefix: 'reporting',
|
||||
|
|
@ -85,11 +85,45 @@ export function createReporting(pluginOptions: HivePluginOptions): SchemaReporte
|
|||
);
|
||||
|
||||
return {
|
||||
report({ schema }) {
|
||||
async report({ schema }) {
|
||||
try {
|
||||
agent.capture(schema);
|
||||
const result = await agent.sendImmediately(schema);
|
||||
|
||||
if (result === null) {
|
||||
throw new Error('Empty response');
|
||||
}
|
||||
|
||||
if (Array.isArray(result.errors)) {
|
||||
throw new Error(result.errors.map(error => error.message).join('\n'));
|
||||
}
|
||||
|
||||
const data = result.data!.schemaPublish;
|
||||
|
||||
switch (data.__typename) {
|
||||
case 'SchemaPublishSuccess': {
|
||||
logger.info(`[hive][reporting] ${data.successMessage ?? 'Published schema'}`);
|
||||
return;
|
||||
}
|
||||
case 'SchemaPublishMissingServiceError': {
|
||||
throw new Error('Service name is not defined');
|
||||
}
|
||||
case 'SchemaPublishMissingUrlError': {
|
||||
throw new Error('Service url is not defined');
|
||||
}
|
||||
case 'SchemaPublishError': {
|
||||
logger.info(`[hive][reporting] Published schema (forced with ${data.errors.total} errors)`);
|
||||
data.errors.nodes.slice(0, 5).forEach(error => {
|
||||
logger.info(` - ${error.message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to report schema`, error);
|
||||
logger.error(
|
||||
`[hive][reporting] Failed to report schema: ${
|
||||
error instanceof Error && 'message' in error ? error.message : error
|
||||
}`
|
||||
);
|
||||
}
|
||||
},
|
||||
dispose: agent.dispose,
|
||||
|
|
@ -100,6 +134,26 @@ const query = stripIgnoredCharacters(/* GraphQL */ `
|
|||
mutation schemaPublish($input: SchemaPublishInput!) {
|
||||
schemaPublish(input: $input) {
|
||||
__typename
|
||||
... on SchemaPublishSuccess {
|
||||
initial
|
||||
valid
|
||||
successMessage: message
|
||||
}
|
||||
... on SchemaPublishError {
|
||||
valid
|
||||
errors {
|
||||
nodes {
|
||||
message
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
... on SchemaPublishMissingServiceError {
|
||||
missingServiceError: message
|
||||
}
|
||||
... on SchemaPublishMissingUrlError {
|
||||
missingUrlError: message
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,9 @@ test('should not leak the exception', async () => {
|
|||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('[hive][reporting] Attempt 1 failed:'));
|
||||
expect(logger.info).toHaveBeenCalledWith('[hive][reporting] Sending (queue 1) (attempt 2)');
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(`[hive][reporting] Failed to send data`));
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`[hive][reporting] Failed to report schema: connect ECONNREFUSED`)
|
||||
);
|
||||
});
|
||||
|
||||
test('should send data to Hive', async () => {
|
||||
|
|
@ -79,7 +81,18 @@ test('should send data to Hive', async () => {
|
|||
.once()
|
||||
.reply((_, _body) => {
|
||||
body = _body;
|
||||
return [200];
|
||||
return [
|
||||
200,
|
||||
{
|
||||
data: {
|
||||
schemaPublish: {
|
||||
__typename: 'SchemaPublishSuccess',
|
||||
initial: false,
|
||||
valid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const hive = createHive({
|
||||
|
|
@ -124,7 +137,7 @@ test('should send data to Hive', async () => {
|
|||
expect(body.variables.input.force).toBe(true);
|
||||
});
|
||||
|
||||
test.only('should send data to Hive immediately', async () => {
|
||||
test('should send data to Hive immediately', async () => {
|
||||
const logger = {
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
|
|
@ -146,7 +159,19 @@ test.only('should send data to Hive immediately', async () => {
|
|||
.once()
|
||||
.reply((_, _body) => {
|
||||
body = _body;
|
||||
return [200];
|
||||
return [
|
||||
200,
|
||||
{
|
||||
data: {
|
||||
schemaPublish: {
|
||||
__typename: 'SchemaPublishSuccess',
|
||||
initial: false,
|
||||
valid: true,
|
||||
successMessage: 'Successfully published schema',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const hive = createHive({
|
||||
|
|
@ -183,7 +208,8 @@ test.only('should send data to Hive immediately', async () => {
|
|||
expect(logger.info).toHaveBeenCalledWith('[hive][reporting] Sending (queue 1) (attempt 1)');
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(`[hive][reporting] Sent!`);
|
||||
expect(logger.info).toHaveBeenCalledTimes(3);
|
||||
expect(logger.info).toHaveBeenCalledWith(`[hive][reporting] Successfully published schema`);
|
||||
expect(logger.info).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(body.variables.input.sdl).toBe(`type Query{foo:String}`);
|
||||
expect(body.variables.input.author).toBe(author);
|
||||
|
|
@ -193,7 +219,7 @@ test.only('should send data to Hive immediately', async () => {
|
|||
expect(body.variables.input.force).toBe(true);
|
||||
|
||||
await waitFor(400);
|
||||
expect(logger.info).toHaveBeenCalledTimes(3);
|
||||
expect(logger.info).toHaveBeenCalledTimes(4);
|
||||
|
||||
await hive.dispose();
|
||||
http.done();
|
||||
|
|
@ -262,3 +288,123 @@ test('should send original schema of a federated service', async () => {
|
|||
expect(body.variables.input.url).toBe(serviceUrl);
|
||||
expect(body.variables.input.force).toBe(true);
|
||||
});
|
||||
|
||||
test('should display SchemaPublishMissingServiceError', async () => {
|
||||
const logger = {
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
|
||||
const token = 'Token';
|
||||
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) => {
|
||||
return [
|
||||
200,
|
||||
{
|
||||
data: {
|
||||
schemaPublish: {
|
||||
__typename: 'SchemaPublishMissingServiceError',
|
||||
missingServiceError: 'Service name is required',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const hive = createHive({
|
||||
enabled: true,
|
||||
debug: true,
|
||||
agent: {
|
||||
timeout: 500,
|
||||
maxRetries: 1,
|
||||
logger,
|
||||
},
|
||||
token: token,
|
||||
reporting: {
|
||||
author: 'Test',
|
||||
commit: 'Commit',
|
||||
endpoint: 'http://localhost/200',
|
||||
},
|
||||
});
|
||||
|
||||
hive.reportSchema({
|
||||
schema: buildSchema(/* GraphQL */ `
|
||||
type Query {
|
||||
foo: String
|
||||
}
|
||||
`),
|
||||
});
|
||||
|
||||
await waitFor(50);
|
||||
await hive.dispose();
|
||||
http.done();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('[hive][reporting] Sending (queue 1) (attempt 1)');
|
||||
expect(logger.error).toHaveBeenCalledWith(`[hive][reporting] Failed to report schema: Service name is not defined`);
|
||||
});
|
||||
|
||||
test('should display SchemaPublishMissingUrlError', async () => {
|
||||
const logger = {
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
|
||||
const token = 'Token';
|
||||
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) => {
|
||||
return [
|
||||
200,
|
||||
{
|
||||
data: {
|
||||
schemaPublish: {
|
||||
__typename: 'SchemaPublishMissingUrlError',
|
||||
missingUrlError: 'Service url is required',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const hive = createHive({
|
||||
enabled: true,
|
||||
debug: true,
|
||||
agent: {
|
||||
timeout: 500,
|
||||
maxRetries: 1,
|
||||
logger,
|
||||
},
|
||||
token: token,
|
||||
reporting: {
|
||||
author: 'Test',
|
||||
commit: 'Commit',
|
||||
endpoint: 'http://localhost/200',
|
||||
},
|
||||
});
|
||||
|
||||
hive.reportSchema({
|
||||
schema: buildSchema(/* GraphQL */ `
|
||||
type Query {
|
||||
foo: String
|
||||
}
|
||||
`),
|
||||
});
|
||||
|
||||
await waitFor(50);
|
||||
await hive.dispose();
|
||||
http.done();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('[hive][reporting] Sending (queue 1) (attempt 1)');
|
||||
expect(logger.error).toHaveBeenCalledWith(`[hive][reporting] Failed to report schema: Service url is not defined`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue