Show error messages in the client when publishing the schema (#272)

Closes #267
This commit is contained in:
Kamil Kisiela 2022-08-12 10:15:19 +02:00 committed by GitHub
parent 617192779b
commit ef18a38cca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 282 additions and 39 deletions

View file

@ -0,0 +1,5 @@
---
'@graphql-hive/client': patch
---
Show error messages when publishing the schema

View file

@ -15,3 +15,4 @@ __generated__/
# test fixtures
integration-tests/fixtures/init-invalid-schema.graphql
/target

View file

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

View file

@ -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}/`,

View file

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

View file

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

View file

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

View file

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