Skip to content

Commit c1d9c05

Browse files
authored
feat(api): filter private schema into public schema using federation contracts (#6614)
1 parent 3ee199c commit c1d9c05

File tree

19 files changed

+391
-132
lines changed

19 files changed

+391
-132
lines changed

.changeset/seven-rivers-sip.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'hive': minor
3+
---
4+
5+
Add new route `/graphql-public` to the `server` service which contains the public GraphQL API
6+
(fields and types will follow).

packages/services/api/src/modules/schema/module.graphql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export default gql`
290290
"""
291291
Whether the CLI supports retrying the schema publish, in case acquiring the schema publish lock fails due to a busy queue.
292292
"""
293-
supportsRetry: Boolean = False
293+
supportsRetry: Boolean = false
294294
}
295295
296296
input SchemaComposeInput {

packages/services/api/src/modules/shared/module.graphql.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,22 @@ export default gql`
77
scalar JSONSchemaObject
88
scalar SafeInt
99
10+
extend schema
11+
@link(url: "https://specs.apollo.dev/link/v1.0")
12+
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"])
13+
14+
directive @link(url: String!, import: [String!]) repeatable on SCHEMA
15+
16+
directive @tag(
17+
name: String!
18+
) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA
19+
1020
type Query {
11-
noop: Boolean
21+
_: Boolean @tag(name: "public")
1222
}
1323
1424
type Mutation {
15-
noop(noop: String): Boolean
25+
_: Boolean @tag(name: "public")
1626
}
1727
1828
type PageInfo {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { MutationResolvers } from './../../../../__generated__/types';
2+
3+
export const _: NonNullable<MutationResolvers['_']> = async (_parent, _arg, _ctx) => {
4+
return null;
5+
};

packages/services/api/src/modules/shared/resolvers/Mutation/noop.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { QueryResolvers } from './../../../../__generated__/types';
2+
3+
export const _: NonNullable<QueryResolvers['_']> = async (_parent, _arg, _ctx) => {
4+
return null;
5+
};

packages/services/api/src/modules/shared/resolvers/Query/noop.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

packages/services/schema/src/lib/compose.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export type SubgraphInput = {
9494

9595
export function composeFederationV2(
9696
subgraphs: Array<SubgraphInput>,
97-
logger: ServiceLogger,
97+
logger?: ServiceLogger,
9898
): ComposerMethodResult & {
9999
includesException?: boolean;
100100
} {
@@ -121,7 +121,7 @@ export function composeFederationV2(
121121
includesNetworkError: false,
122122
} as const;
123123
} catch (error) {
124-
logger.error(error);
124+
logger?.error(error);
125125
Sentry.captureException(error);
126126

127127
return {

packages/services/server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
"@fastify/cors": "9.0.1",
2323
"@fastify/formbody": "7.4.0",
2424
"@graphql-hive/yoga": "workspace:*",
25+
"@graphql-tools/merge": "9.0.24",
2526
"@graphql-yoga/plugin-persisted-operations": "3.9.0",
2627
"@graphql-yoga/plugin-response-cache": "3.9.0",
2728
"@graphql-yoga/redis-event-target": "3.0.2",
2829
"@hive/api": "workspace:*",
2930
"@hive/cdn-script": "workspace:*",
31+
"@hive/schema": "workspace:*",
3032
"@hive/service-common": "workspace:*",
3133
"@hive/storage": "workspace:*",
3234
"@sentry/integrations": "7.114.0",

packages/services/server/src/graphql-handler.ts

Lines changed: 92 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import type { HivePersistedDocumentsConfig, HiveUsageConfig } from './environmen
3333
import { useArmor } from './use-armor';
3434
import { extractUserId, useSentryUser } from './use-sentry-user';
3535

36-
const reqIdGenerate = hyperid({ fixedLength: true });
36+
export const reqIdGenerate = hyperid({ fixedLength: true });
3737

3838
function hashSessionId(sessionId: string): string {
3939
return createHash('sha256').update(sessionId).digest('hex');
@@ -56,7 +56,7 @@ export interface GraphQLHandlerOptions {
5656
authN: AuthN;
5757
}
5858

59-
interface Context extends RegistryContext {
59+
export interface Context extends RegistryContext {
6060
req: FastifyRequest;
6161
reply: FastifyReply;
6262
session: Session;
@@ -80,6 +80,30 @@ function hasFastifyRequest(ctx: unknown): ctx is {
8080
return !!ctx && typeof ctx === 'object' && 'req' in ctx;
8181
}
8282

83+
export function useHiveErrorHandler(fallbackHandler: (err: Error) => void): Plugin {
84+
return useErrorHandler(({ errors, context }): void => {
85+
// Not sure what changed, but the `context` is now an object with a contextValue property.
86+
// We previously relied on the `context` being the `contextValue` itself.
87+
const ctx = ('contextValue' in context ? context.contextValue : context) as Context;
88+
89+
for (const error of errors) {
90+
if (isGraphQLError(error) && error.originalError) {
91+
console.error(error);
92+
console.error(error.originalError);
93+
continue;
94+
} else {
95+
console.error(error);
96+
}
97+
98+
if (hasFastifyRequest(ctx)) {
99+
ctx.req.log.error(error);
100+
} else {
101+
fallbackHandler(error);
102+
}
103+
}
104+
});
105+
}
106+
83107
function useNoIntrospection(params: {
84108
signature: string;
85109
isNonProductionEnvironment: boolean;
@@ -95,72 +119,77 @@ function useNoIntrospection(params: {
95119
};
96120
}
97121

122+
export function useHiveSentry() {
123+
return useSentry({
124+
startTransaction: false,
125+
renameTransaction: false,
126+
/**
127+
* When it's not `null`, the plugin modifies the error object.
128+
* We end up with an unintended error masking, because the GraphQLYogaError is replaced with GraphQLError (without error.originalError).
129+
*/
130+
eventIdKey: null,
131+
operationName: () => 'graphql',
132+
includeRawResult: false,
133+
includeResolverArgs: false,
134+
includeExecuteVariables: true,
135+
configureScope(args, scope) {
136+
// Get the operation name from the request, or use the operation name from the document.
137+
const operationName =
138+
args.operationName ??
139+
args.document.definitions.find(isOperationDefinitionNode)?.name?.value ??
140+
'unknown';
141+
142+
scope.setContext('Extra Info', {
143+
operationName,
144+
variables: JSON.stringify(args.variableValues),
145+
operation: print(args.document),
146+
userId: extractUserId(args.contextValue as any),
147+
});
148+
},
149+
appendTags: ({ contextValue }) => {
150+
const supertokens_user_id = extractUserId(contextValue as any);
151+
const request_id = (contextValue as Context).requestId;
152+
153+
return {
154+
supertokens_user_id,
155+
request_id,
156+
};
157+
},
158+
skip(args) {
159+
// It's the readiness check
160+
return args.operationName === 'readiness';
161+
},
162+
});
163+
}
164+
165+
export function useHiveTracing(tracingProvider?: Parameters<typeof useOpenTelemetry>[1]) {
166+
return useOpenTelemetry(
167+
{
168+
document: true,
169+
resolvers: false,
170+
result: false,
171+
variables: variables => {
172+
if (variables && typeof variables === 'object' && 'selector' in variables) {
173+
return JSON.stringify(variables.selector);
174+
}
175+
176+
return '';
177+
},
178+
excludedOperationNames: ['readiness'],
179+
},
180+
tracingProvider,
181+
);
182+
}
183+
98184
export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMethod => {
99185
const server = createYoga<Context>({
100186
logging: options.logger,
101187
plugins: [
102188
useArmor(),
103-
useSentry({
104-
startTransaction: false,
105-
renameTransaction: false,
106-
/**
107-
* When it's not `null`, the plugin modifies the error object.
108-
* We end up with an unintended error masking, because the GraphQLYogaError is replaced with GraphQLError (without error.originalError).
109-
*/
110-
eventIdKey: null,
111-
operationName: () => 'graphql',
112-
includeRawResult: false,
113-
includeResolverArgs: false,
114-
includeExecuteVariables: true,
115-
configureScope(args, scope) {
116-
// Get the operation name from the request, or use the operation name from the document.
117-
const operationName =
118-
args.operationName ??
119-
args.document.definitions.find(isOperationDefinitionNode)?.name?.value ??
120-
'unknown';
121-
122-
scope.setContext('Extra Info', {
123-
operationName,
124-
variables: JSON.stringify(args.variableValues),
125-
operation: print(args.document),
126-
userId: extractUserId(args.contextValue as any),
127-
});
128-
},
129-
appendTags: ({ contextValue }) => {
130-
const supertokens_user_id = extractUserId(contextValue as any);
131-
const request_id = (contextValue as Context).requestId;
132-
133-
return {
134-
supertokens_user_id,
135-
request_id,
136-
};
137-
},
138-
skip(args) {
139-
// It's the readiness check
140-
return args.operationName === 'readiness';
141-
},
142-
}),
189+
useHiveSentry(),
143190
useSentryUser(),
144-
useErrorHandler(({ errors, context }): void => {
145-
// Not sure what changed, but the `context` is now an object with a contextValue property.
146-
// We previously relied on the `context` being the `contextValue` itself.
147-
const ctx = ('contextValue' in context ? context.contextValue : context) as Context;
148-
149-
for (const error of errors) {
150-
if (isGraphQLError(error) && error.originalError) {
151-
console.error(error);
152-
console.error(error.originalError);
153-
continue;
154-
} else {
155-
console.error(error);
156-
}
157-
158-
if (hasFastifyRequest(ctx)) {
159-
ctx.req.log.error(error);
160-
} else {
161-
server.logger.error(error);
162-
}
163-
}
191+
useHiveErrorHandler(error => {
192+
server.logger.error(error);
164193
}),
165194
useExtendContext(async context => ({
166195
session: await options.authN.authenticate(context),
@@ -237,24 +266,7 @@ export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMeth
237266
},
238267
},
239268
),
240-
options.tracing
241-
? useOpenTelemetry(
242-
{
243-
document: true,
244-
resolvers: false,
245-
result: false,
246-
variables: variables => {
247-
if (variables && typeof variables === 'object' && 'selector' in variables) {
248-
return JSON.stringify(variables.selector);
249-
}
250-
251-
return '';
252-
},
253-
excludedOperationNames: ['readiness'],
254-
},
255-
options.tracing.traceProvider(),
256-
)
257-
: {},
269+
options.tracing ? useHiveTracing(options.tracing.traceProvider()) : {},
258270
useExecutionCancellation(),
259271
],
260272
graphiql: !options.isProduction,

0 commit comments

Comments
 (0)