Skip to content

Commit

Permalink
Support @apollo/server and Federation v2 in Hive Plugin for Apollo (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
kamilkisiela authored Feb 10, 2023
1 parent 0bf2c7a commit cdf2e8a
Show file tree
Hide file tree
Showing 12 changed files with 371 additions and 206 deletions.
5 changes: 5 additions & 0 deletions .changeset/hip-needles-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-hive/client': minor
---

Support Federation v2 in schema reporting
5 changes: 5 additions & 0 deletions .changeset/ninety-windows-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-hive/client': minor
---

Add @apollo/server and @envelop/types as optional dependencies
5 changes: 5 additions & 0 deletions .changeset/soft-gifts-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-hive/client': minor
---

Support @apollo/server
8 changes: 6 additions & 2 deletions packages/libraries/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@
"axios": "^1.2.1",
"tiny-lru": "8.0.2"
},
"optionalDependencies": {
"@apollo/server": "^4.0.0",
"@envelop/types": "^3.0.0"
},
"devDependencies": {
"@apollo/federation": "0.38.1",
"@apollo/server": "4.3.3",
"@apollo/subgraph": "2.3.1",
"@envelop/types": "3.0.1",
"@types/async-retry": "1.4.5",
"apollo-server-core": "3.11.1",
"apollo-server-plugin-base": "3.7.1",
"graphql-yoga": "3.5.1",
"nock": "13.3.0"
},
Expand Down
27 changes: 24 additions & 3 deletions packages/libraries/client/src/apollo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createHash } from 'crypto';
import type { ApolloServerPlugin } from 'apollo-server-plugin-base';
import axios from 'axios';
import type { DocumentNode } from 'graphql';
import type { ApolloServerPlugin } from '@apollo/server';
import { createHive } from './client.js';
import type {
HiveClient,
Expand Down Expand Up @@ -149,6 +149,8 @@ export function hiveApollo(clientOrOptions: HiveClient | HivePluginOptions): Apo
requestDidStart(context) {
// `overallCachePolicy` does not exist in v0
const isLegacyV0 = !('overallCachePolicy' in context);
// `context` does not exist in v4, it is `contextValue` instead
const isLegacyV3 = 'context' in context;

let doc: DocumentNode;
const complete = hive.collectUsage({
Expand All @@ -157,7 +159,7 @@ export function hiveApollo(clientOrOptions: HiveClient | HivePluginOptions): Apo
return doc;
},
operationName: context.operationName,
contextValue: context.context,
contextValue: isLegacyV3 ? context.context : context.contextValue,
variableValues: context.request.variables,
});

Expand All @@ -170,10 +172,27 @@ export function hiveApollo(clientOrOptions: HiveClient | HivePluginOptions): Apo
} as any;
}

if (isLegacyV3) {
return Promise.resolve({
async willSendResponse(ctx) {
doc = ctx.document!;
complete(ctx.response as any);
},
});
}

// v4
return Promise.resolve({
async willSendResponse(ctx) {
doc = ctx.document!;
complete(ctx.response);
if (ctx.response.body.kind === 'incremental') {
complete({
action: 'abort',
reason: '@defer and @stream is not supported by Hive',
});
} else {
complete(ctx.response.body.singleResult);
}
},
});
},
Expand All @@ -191,6 +210,8 @@ export function hiveApollo(clientOrOptions: HiveClient | HivePluginOptions): Apo
} as any;
}

// Works on v3 and v4

return Promise.resolve({
async serverWillStop() {
await hive.dispose();
Expand Down
66 changes: 65 additions & 1 deletion packages/libraries/client/src/internal/reporting.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { ExecutionResult, GraphQLSchema, Kind, print, stripIgnoredCharacters } from 'graphql';
import {
ExecutionResult,
GraphQLSchema,
Kind,
parse,
print,
stripIgnoredCharacters,
visit,
} from 'graphql';
import { getDocumentNodeFromSchema } from '@graphql-tools/utils';
import type { SchemaPublishMutation } from '../__generated__/types.js';
import { version } from '../version.js';
Expand Down Expand Up @@ -187,6 +195,23 @@ function isFederatedSchema(schema: GraphQLSchema): boolean {
return false;
}

const federationV2 = {
scalars: new Set(['_Any', '_FieldSet']),
directives: new Set([
'key',
'requires',
'provides',
'external',
'shareable',
'extends',
'override',
'inaccessible',
'tag',
]),
types: new Set(['_Service']),
queryFields: new Set(['_service']),
};

/**
* Extracts the SDL of a federated service from a GraphQLSchema object
* We do it to not send federated schema to the registry but only the original schema provided by user
Expand All @@ -195,6 +220,45 @@ async function extractFederationServiceSDL(schema: GraphQLSchema): Promise<strin
const queryType = schema.getQueryType()!;
const serviceField = queryType.getFields()._service;
const resolved = await (serviceField.resolve as () => Promise<{ sdl: string }>)();

if (resolved.sdl.includes('_service')) {
// It seems that the schema is a federated (v2) schema.
// The _service field returns the SDL of the whole subgraph, not only the sdl provided by the user.
// We want to remove the federation specific types and directives from the SDL.
return print(
visit(parse(resolved.sdl), {
ScalarTypeDefinition(node) {
if (federationV2.scalars.has(node.name.value)) {
return null;
}

return node;
},
DirectiveDefinition(node) {
if (federationV2.directives.has(node.name.value)) {
return null;
}

return node;
},
ObjectTypeDefinition(node) {
if (federationV2.types.has(node.name.value)) {
return null;
}

if (node.name.value === 'Query' && node.fields) {
return {
...node,
fields: node.fields.filter(field => !federationV2.queryFields.has(field.name.value)),
};
}

return node;
},
}),
);
}

return resolved.sdl;
}

Expand Down
10 changes: 9 additions & 1 deletion packages/libraries/client/src/internal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ export interface HiveClient {
}

export type AsyncIterableIteratorOrValue<T> = AsyncIterableIterator<T> | T;
export type AsyncIterableOrValue<T> = AsyncIterable<T> | T;
export type AbortAction = {
action: 'abort';
reason: string;
};

export type CollectUsageCallback = (
result: AsyncIterableIteratorOrValue<GraphQLErrorsResult>,
result:
| AsyncIterableIteratorOrValue<GraphQLErrorsResult>
| AsyncIterableOrValue<GraphQLErrorsResult>
| AbortAction,
) => void;
export interface ClientInfo {
name: string;
Expand Down
14 changes: 13 additions & 1 deletion packages/libraries/client/src/internal/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { version } from '../version.js';
import { createAgent } from './agent.js';
import { randomSampling } from './sampling.js';
import type {
AbortAction,
ClientInfo,
CollectUsageCallback,
HivePluginOptions,
Expand All @@ -37,6 +38,7 @@ import type {
import {
cache,
cacheDocumentKey,
isAsyncIterable,
isAsyncIterableIterator,
logIf,
measureDuration,
Expand All @@ -48,6 +50,10 @@ interface UsageCollector {
dispose(): Promise<void>;
}

function isAbortAction(result: Parameters<CollectUsageCallback>[0]): result is AbortAction {
return 'action' in result && result.action === 'abort';
}

export function createUsage(pluginOptions: HivePluginOptions): UsageCollector {
if (!pluginOptions.usage || pluginOptions.enabled === false) {
return {
Expand Down Expand Up @@ -150,7 +156,13 @@ export function createUsage(pluginOptions: HivePluginOptions): UsageCollector {

return function complete(result) {
try {
if (isAsyncIterableIterator(result)) {
if (isAbortAction(result)) {
logger.info(result.reason);
finish();
return;
}

if (isAsyncIterableIterator(result) || isAsyncIterable(result)) {
logger.info('@stream @defer is not supported');
finish();
return;
Expand Down
11 changes: 10 additions & 1 deletion packages/libraries/client/src/internal/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { createHash } from 'crypto';
import type { AsyncIterableIteratorOrValue, HiveClient, HivePluginOptions } from './types.js';
import type {
AsyncIterableIteratorOrValue,
AsyncIterableOrValue,
HiveClient,
HivePluginOptions,
} from './types.js';

export function isAsyncIterableIterator<T>(
value: AsyncIterableIteratorOrValue<T>,
): value is AsyncIterableIterator<T> {
return typeof (value as any)?.[Symbol.asyncIterator] === 'function';
}

export function isAsyncIterable<T>(value: AsyncIterableOrValue<T>): value is AsyncIterable<T> {
return typeof (value as any)?.[Symbol.asyncIterator] === 'function';
}

export function memo<R, A, K>(fn: (arg: A) => R, cacheKeyFn: (arg: A) => K): (arg: A) => R {
let memoizedResult: R | null = null;
let memoizedKey: K | null = null;
Expand Down
8 changes: 3 additions & 5 deletions packages/libraries/client/tests/integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { createServer } from 'node:http';
import { AddressInfo } from 'node:net';

/* eslint-disable-next-line import/no-extraneous-dependencies */
import { ApolloServerBase } from 'apollo-server-core';
import axios from 'axios';

/* eslint-disable-next-line import/no-extraneous-dependencies */
import { createSchema, createYoga } from 'graphql-yoga';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ApolloServer } from '@apollo/server';
import { createHive, hiveApollo, useHive } from '../src';
import { waitFor } from './test-utils';

Expand Down Expand Up @@ -110,7 +109,7 @@ test('Apollo Server - should not interrupt the process', async () => {
info: jest.fn(),
};
const clean = handleProcess();
const apollo = new ApolloServerBase({
const apollo = new ApolloServer({
typeDefs,
resolvers,
plugins: [
Expand All @@ -136,7 +135,6 @@ test('Apollo Server - should not interrupt the process', async () => {
],
});

await apollo.start();
await apollo.executeOperation({
query: /* GraphQL */ `
{
Expand Down
72 changes: 69 additions & 3 deletions packages/libraries/client/tests/reporting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { buildSchema, parse } from 'graphql';
// eslint-disable-next-line import/no-extraneous-dependencies
import nock from 'nock';
// eslint-disable-next-line import/no-extraneous-dependencies
import { buildSubgraphSchema } from '@apollo/federation';
import { buildSubgraphSchema as buildSubgraphSchemaV1 } from '@apollo/federation';
// eslint-disable-next-line import/no-extraneous-dependencies
import { buildSubgraphSchema as buildSubgraphSchemaV2 } from '@apollo/subgraph';
import { createHive } from '../src/client';
import { version } from '../src/version';
import { waitFor } from './test-utils';
Expand Down Expand Up @@ -381,7 +383,71 @@ test('should send data to Hive immediately', async () => {
http.done();
});

test('should send original schema of a federated service', async () => {
test('should send original schema of a federated (v1) service', 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';

const hive = createHive({
enabled: true,
debug: true,
agent: {
timeout: 500,
maxRetries: 1,
logger,
},
token,
reporting: {
author,
commit,
endpoint: 'http://localhost/200',
serviceUrl,
serviceName,
},
});

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];
});

hive.reportSchema({
schema: buildSubgraphSchemaV1(
parse(/* GraphQL */ `
type Query {
bar: String
}
`),
),
});

await hive.dispose();
http.done();

expect(body.variables.input.sdl).toBe(`type Query{bar: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 original schema of a federated (v2) service', async () => {
const logger = {
error: jest.fn(),
info: jest.fn(),
Expand Down Expand Up @@ -425,7 +491,7 @@ test('should send original schema of a federated service', async () => {
});

hive.reportSchema({
schema: buildSubgraphSchema(
schema: buildSubgraphSchemaV2(
parse(/* GraphQL */ `
type Query {
bar: String
Expand Down
Loading

0 comments on commit cdf2e8a

Please sign in to comment.