Skip to content

Commit d0404db

Browse files
authored
Improved external composition UX (#6752)
1 parent a8b775f commit d0404db

File tree

8 files changed

+152
-63
lines changed

8 files changed

+152
-63
lines changed

.changeset/tasty-poems-run.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'hive': patch
3+
---
4+
5+
Improve external composer UX: Handle network errors gracefully, do not use native composer when
6+
testing, and improve settings UI

packages/services/api/src/modules/schema/providers/schema-manager.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -447,15 +447,10 @@ export class SchemaManager {
447447
},
448448
});
449449

450-
const [project, organization] = await Promise.all([
451-
this.storage.getProject({
452-
organizationId: selector.organizationId,
453-
projectId: selector.projectId,
454-
}),
455-
this.storage.getOrganization({
456-
organizationId: selector.organizationId,
457-
}),
458-
]);
450+
const project = await this.storage.getProject({
451+
organizationId: selector.organizationId,
452+
projectId: selector.projectId,
453+
});
459454

460455
if (project.type !== ProjectType.FEDERATION) {
461456
throw new HiveError(
@@ -481,11 +476,8 @@ export class SchemaManager {
481476
],
482477
{
483478
external: project.externalComposition,
484-
native: this.checkProjectNativeFederationSupport({
485-
project,
486-
organization,
487-
targetId: null,
488-
}),
479+
// when testing external composition, do not use native composition during the check
480+
native: false,
489481
contracts: null,
490482
},
491483
);

packages/services/schema/src/composition-worker.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as crypto from 'node:crypto';
22
import type { MessagePort } from 'node:worker_threads';
3+
// IMPORTANT: Must use "type" to avoid runtime dependency
4+
import type { FastifyBaseLogger } from 'fastify';
35
import type { Logger } from '@hive/api';
46
import { CompositionResponse } from './api';
57
import { createComposeFederation, type ComposeFederationArgs } from './composition/federation';
@@ -51,7 +53,7 @@ export function createCompositionWorker(args: {
5153
if (message.data.type === 'federation') {
5254
const composeFederation = createComposeFederation({
5355
decrypt,
54-
logger: baseLogger.child({ reqId: message.data.args.requestId }) as any,
56+
logger: baseLogger.child({ reqId: message.data.args.requestId }) as FastifyBaseLogger,
5557
requestTimeoutMs: message.data.requestTimeoutMs,
5658
});
5759
const composed = await composeFederation(message.data.args);

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

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,21 @@ const EXTERNAL_COMPOSITION_RESULT = z.union([
3232
supergraph: z.string(),
3333
sdl: z.string(),
3434
}),
35-
includesNetworkError: z.boolean().optional().default(false),
35+
includesNetworkError: z.boolean().default(false),
3636
}),
3737
z.object({
3838
type: z.literal('failure'),
3939
result: z.object({
40-
supergraph: z.string().optional(),
41-
sdl: z.string().optional(),
40+
supergraph: z.string().nullish(),
41+
sdl: z.string().nullish(),
4242
errors: z.array(
4343
z.object({
4444
message: z.string(),
45-
source: z
46-
.union([z.literal('composition'), z.literal('graphql')])
47-
.optional()
48-
.transform(value => value ?? 'graphql'),
45+
source: z.union([z.literal('composition'), z.literal('graphql')]).default('graphql'),
4946
}),
5047
),
5148
}),
52-
includesNetworkError: z.boolean().optional().default(false),
49+
includesNetworkError: z.boolean().default(false),
5350
}),
5451
]);
5552

@@ -198,8 +195,20 @@ export async function composeExternalFederation(args: {
198195

199196
if (!parseResult.success) {
200197
args.logger.error('External composition failure: invalid shape of data: %o', parseResult.error);
201-
202-
throw new Error(`External composition failure: invalid shape of data`);
198+
return {
199+
type: 'failure',
200+
result: {
201+
supergraph: null,
202+
sdl: null,
203+
errors: [
204+
{
205+
message: 'External composition failure: invalid shape of data',
206+
source: 'composition',
207+
},
208+
],
209+
},
210+
includesNetworkError: false,
211+
};
203212
}
204213

205214
if (parseResult.data.type === 'success') {
@@ -314,6 +323,10 @@ async function callExternalService(
314323
},
315324
timeout: {
316325
request: timeoutMs,
326+
// connecting should be quick
327+
lookup: 10_000,
328+
connect: 10_000,
329+
secureConnect: 10_000,
317330
},
318331
});
319332

@@ -329,7 +342,7 @@ async function callExternalService(
329342
span.setAttribute('error.type', error.name);
330343

331344
logger.error(
332-
'Network error without response. (errorName=%s, errorMessage=%s)',
345+
'Network error during external composition without response. (errorName=%s, errorMessage=%s)',
333346
error.name,
334347
error.message,
335348
);
@@ -341,7 +354,7 @@ async function callExternalService(
341354
supergraph: null,
342355
errors: [
343356
{
344-
message: `External composition network failure. Is the service reachable?`,
357+
message: `A network error occurred during external composition: "${error.message}"`,
345358
source: 'graphql',
346359
},
347360
],

packages/web/app/src/components/project/settings/external-composition.tsx

Lines changed: 104 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useState } from 'react';
1+
import { useCallback, useEffect, useState } from 'react';
22
import { useForm } from 'react-hook-form';
33
import { useMutation, useQuery } from 'urql';
44
import { z } from 'zod';
@@ -21,7 +21,7 @@ import { Switch } from '@/components/v2';
2121
import { FragmentType, graphql, useFragment } from '@/gql';
2222
import { useNotifications } from '@/lib/hooks';
2323
import { zodResolver } from '@hookform/resolvers/zod';
24-
import { CheckIcon, Cross2Icon, UpdateIcon } from '@radix-ui/react-icons';
24+
import { CheckIcon, Cross2Icon, ReloadIcon, UpdateIcon } from '@radix-ui/react-icons';
2525

2626
const ExternalCompositionStatus_TestQuery = graphql(`
2727
query ExternalCompositionStatus_TestQuery($selector: TestExternalSchemaCompositionInput!) {
@@ -73,14 +73,20 @@ const ExternalCompositionForm_ProjectFragment = graphql(`
7373
}
7474
`);
7575

76+
enum TestState {
77+
LOADING,
78+
ERROR,
79+
SUCCESS,
80+
}
81+
7682
const ExternalCompositionStatus = ({
7783
projectSlug,
7884
organizationSlug,
7985
}: {
8086
projectSlug: string;
8187
organizationSlug: string;
8288
}) => {
83-
const [query] = useQuery({
89+
const [{ data, error: gqlError, fetching }, executeTestQuery] = useQuery({
8490
query: ExternalCompositionStatus_TestQuery,
8591
variables: {
8692
selector: {
@@ -90,33 +96,84 @@ const ExternalCompositionStatus = ({
9096
},
9197
requestPolicy: 'network-only',
9298
});
99+
const error = gqlError?.message ?? data?.testExternalSchemaComposition?.error?.message;
100+
const testState = fetching
101+
? TestState.LOADING
102+
: error
103+
? TestState.ERROR
104+
: data?.testExternalSchemaComposition?.ok?.externalSchemaComposition?.endpoint
105+
? TestState.SUCCESS
106+
: null;
107+
108+
const [hidden, setHidden] = useState<boolean>();
109+
110+
useEffect(() => {
111+
// only hide the success icon after the duration
112+
if (testState !== TestState.SUCCESS) return;
113+
const timerId = setTimeout(() => {
114+
if (testState === TestState.SUCCESS) {
115+
setHidden(false);
116+
}
117+
}, 5000);
93118

94-
const error = query.error?.message ?? query.data?.testExternalSchemaComposition?.error?.message;
119+
return () => {
120+
clearTimeout(timerId);
121+
};
122+
}, [testState]);
95123

96124
return (
97125
<TooltipProvider delayDuration={100}>
98-
{query.fetching ? (
126+
{testState === TestState.LOADING ? (
99127
<Tooltip>
100128
<TooltipTrigger>
101-
<UpdateIcon className="size-5 animate-spin text-gray-500" />
129+
<UpdateIcon
130+
className="size-5 animate-spin cursor-default text-gray-500"
131+
onClick={e => e.preventDefault()}
132+
/>
102133
</TooltipTrigger>
103-
<TooltipContent side="right">Connecting...</TooltipContent>
134+
<TooltipContent side="left">Connecting...</TooltipContent>
104135
</Tooltip>
105-
) : null}
106-
{error ? (
136+
) : (
107137
<Tooltip>
108138
<TooltipTrigger>
109-
<Cross2Icon className="size-5 text-red-500" />
139+
<ReloadIcon
140+
className="size-5"
141+
onClick={e => {
142+
e.preventDefault();
143+
setHidden(true);
144+
executeTestQuery();
145+
}}
146+
/>
147+
</TooltipTrigger>
148+
<TooltipContent side="top" className="mr-1">
149+
Execute test
150+
</TooltipContent>
151+
</Tooltip>
152+
)}
153+
{testState === TestState.ERROR ? (
154+
<Tooltip defaultOpen>
155+
<TooltipTrigger>
156+
<Cross2Icon
157+
className="size-5 cursor-default text-red-500"
158+
onClick={e => e.preventDefault()}
159+
/>
110160
</TooltipTrigger>
111-
<TooltipContent side="right">{error}</TooltipContent>
161+
<TooltipContent side="right" className="max-w-sm">
162+
{error}
163+
</TooltipContent>
112164
</Tooltip>
113165
) : null}
114-
{query.data?.testExternalSchemaComposition?.ok?.externalSchemaComposition?.endpoint ? (
166+
{testState === TestState.SUCCESS && !hidden ? (
115167
<Tooltip>
116168
<TooltipTrigger>
117-
<CheckIcon className="size-5 text-green-500" />
169+
<CheckIcon
170+
className="size-5 cursor-default text-green-500"
171+
onClick={e => e.preventDefault()}
172+
/>
118173
</TooltipTrigger>
119-
<TooltipContent side="right">Service is available</TooltipContent>
174+
<TooltipContent side="right" className="max-w-sm">
175+
Service is available
176+
</TooltipContent>
120177
</Tooltip>
121178
) : null}
122179
</TooltipProvider>
@@ -148,6 +205,8 @@ const ExternalCompositionForm = ({
148205
project: FragmentType<typeof ExternalCompositionForm_ProjectFragment>;
149206
organization: FragmentType<typeof ExternalCompositionForm_OrganizationFragment>;
150207
endpoint?: string;
208+
isNativeCompositionEnabled?: boolean;
209+
onClickDisable?: () => void;
151210
}) => {
152211
const project = useFragment(ExternalCompositionForm_ProjectFragment, props.project);
153212
const organization = useFragment(
@@ -176,10 +235,11 @@ const ExternalCompositionForm = ({
176235
},
177236
}).then(result => {
178237
if (result.data?.enableExternalSchemaComposition?.ok) {
179-
notify('External composition enabled', 'success');
180238
const endpoint =
181239
result.data?.enableExternalSchemaComposition?.ok.externalSchemaComposition?.endpoint;
182240

241+
notify('External composition enabled.', 'success');
242+
183243
if (endpoint) {
184244
form.reset(
185245
{
@@ -230,10 +290,10 @@ const ExternalCompositionForm = ({
230290
<FormItem>
231291
<FormLabel>HTTP Endpoint</FormLabel>
232292
<FormDescription>A POST request will be sent to that endpoint</FormDescription>
233-
<div className="flex w-full max-w-sm items-center space-x-2">
293+
<div className="flex w-full items-center space-x-2">
234294
<FormControl>
235295
<Input
236-
className="w-96 shrink-0"
296+
className="max-w-md shrink-0"
237297
placeholder="Endpoint"
238298
type="text"
239299
autoComplete="off"
@@ -265,7 +325,7 @@ const ExternalCompositionForm = ({
265325
</FormDescription>
266326
<FormControl>
267327
<Input
268-
className="w-96"
328+
className="w-full max-w-md"
269329
placeholder="Secret"
270330
type="password"
271331
autoComplete="off"
@@ -279,10 +339,26 @@ const ExternalCompositionForm = ({
279339
{mutation.error && (
280340
<div className="mt-2 text-xs text-red-500">{mutation.error.message}</div>
281341
)}
282-
<div>
342+
{props.isNativeCompositionEnabled ? (
343+
<DocsNote warn className="mt-0 max-w-2xl">
344+
Native Federation v2 composition is currently enabled. Until native composition is
345+
disabled, External Schema Composition won't have any effect.
346+
</DocsNote>
347+
) : null}
348+
<div className="flex flex-row items-center gap-x-8">
283349
<Button type="submit" disabled={form.formState.isSubmitting}>
284-
Save
350+
Save Configuration
285351
</Button>
352+
{props.onClickDisable ? (
353+
<Button
354+
variant="destructive"
355+
type="button"
356+
disabled={mutation.fetching || form.formState.isSubmitting}
357+
onClick={props.onClickDisable}
358+
>
359+
Disable External Composition
360+
</Button>
361+
) : null}
286362
</div>
287363
</form>
288364
</Form>
@@ -403,27 +479,25 @@ export const ExternalCompositionSettings = (props: {
403479
)}
404480
</div>
405481
</CardTitle>
482+
<CardDescription className="max-w-2xl">
483+
For advanced users, you can configure an endpoint for external schema compositions. This
484+
can be used to implement custom composition logic.
485+
</CardDescription>
406486
<CardDescription>
407-
<ProductUpdatesLink href="#native-composition">
408-
Enable native Apollo Federation v2 support in Hive
487+
<ProductUpdatesLink href="https://the-guild.dev/graphql/hive/docs/features/external-schema-composition">
488+
Read about external schema composition in our documentation.
409489
</ProductUpdatesLink>
410490
</CardDescription>
411491
</CardHeader>
412492

413493
<CardContent>
414-
{isNativeCompositionEnabled && isEnabled ? (
415-
<DocsNote warn className={isFormVisible ? 'mb-6 mt-0' : ''}>
416-
It appears that Native Federation v2 Composition is activated and will be used instead.
417-
<br />
418-
External composition won't have any effect.
419-
</DocsNote>
420-
) : null}
421-
422494
{isFormVisible ? (
423495
<ExternalCompositionForm
424496
project={project}
425497
organization={organization}
426498
endpoint={externalCompositionConfig?.endpoint}
499+
isNativeCompositionEnabled={isNativeCompositionEnabled}
500+
onClickDisable={() => handleSwitch(false)}
427501
/>
428502
) : (
429503
<Button disabled={mutation.fetching} onClick={() => handleSwitch(true)}>

packages/web/app/src/components/project/settings/native-composition.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,9 @@ export function NativeCompositionSettings(props: {
227227
<CardTitle>
228228
<a id="native-composition">Native Federation v2 Composition</a>
229229
</CardTitle>
230-
<CardDescription>Native Apollo Federation v2 support for your project.</CardDescription>
230+
<CardDescription>
231+
Recommended for most users. Use native Apollo Federation v2 composition for your project.
232+
</CardDescription>
231233

232234
{display !== 'enabled' ? (
233235
<CardDescription>

0 commit comments

Comments
 (0)