@effect-gql/federation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ import { Effect, Pipeable } from "effect"
2
+ import * as S from "effect/Schema"
3
+ import {
4
+ GraphQLSchemaBuilder,
5
+ GraphQLSchema,
6
+ GraphQLObjectType,
7
+ GraphQLList,
8
+ GraphQLNonNull,
9
+ printSchema,
10
+ type DirectiveApplication,
11
+ } from "@effect-gql/core"
12
+ import { AnyScalar, FieldSetScalar } from "./scalars"
13
+ import {
14
+ createEntityUnion,
15
+ createEntitiesResolver,
16
+ createServiceType,
17
+ createServiceResolver,
18
+ } from "./entities"
19
+ import type { EntityRegistration, FederatedSchemaConfig, FederatedSchemaResult } from "./types"
20
+ import { toDirectiveApplication } from "./types"
21
+
22
+ /**
23
+ * Internal state for the federated builder
24
+ */
25
+ interface FederatedBuilderState<R> {
26
+ /** Underlying core builder */
27
+ coreBuilder: GraphQLSchemaBuilder<R>
28
+ /** Registered entities */
29
+ entities: Map<string, EntityRegistration<any, any>>
30
+ /** Federation version */
31
+ version: string
32
+ }
33
+
34
+ /**
35
+ * Federation-aware schema builder that extends the core GraphQLSchemaBuilder
36
+ * with Apollo Federation 2.x support.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * const schema = FederatedSchemaBuilder.empty
41
+ * .pipe(
42
+ * entity({
43
+ * name: "User",
44
+ * schema: UserSchema,
45
+ * keys: [key({ fields: "id" })],
46
+ * resolveReference: (ref) => UserService.findById(ref.id),
47
+ * }),
48
+ * query("me", {
49
+ * type: UserSchema,
50
+ * resolve: () => UserService.getCurrentUser(),
51
+ * }),
52
+ * )
53
+ * .buildFederatedSchema()
54
+ * ```
55
+ */
56
+ export class FederatedSchemaBuilder<R = never> implements Pipeable.Pipeable {
57
+ private constructor(private readonly state: FederatedBuilderState<R>) {}
58
+
59
+ /**
60
+ * Pipeable interface implementation
61
+ */
62
+ pipe<A>(this: A): A
63
+ pipe<A, B>(this: A, ab: (a: A) => B): B
64
+ pipe<A, B, C>(this: A, ab: (a: A) => B, bc: (b: B) => C): C
65
+ pipe<A, B, C, D>(this: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D
66
+ pipe<A, B, C, D, E>(
67
+ this: A,
68
+ ab: (a: A) => B,
69
+ bc: (b: B) => C,
70
+ cd: (c: C) => D,
71
+ de: (d: D) => E
72
+ ): E
73
+ pipe<A, B, C, D, E, F>(
74
+ this: A,
75
+ ab: (a: A) => B,
76
+ bc: (b: B) => C,
77
+ cd: (c: C) => D,
78
+ de: (d: D) => E,
79
+ ef: (e: E) => F
80
+ ): F
81
+ pipe() {
82
+ return Pipeable.pipeArguments(this, arguments)
83
+ }
84
+
85
+ /**
86
+ * Create an empty federated schema builder
87
+ */
88
+ static empty = new FederatedSchemaBuilder<never>({
89
+ coreBuilder: GraphQLSchemaBuilder.empty,
90
+ entities: new Map(),
91
+ version: "2.3",
92
+ })
93
+
94
+ /**
95
+ * Create a builder with custom configuration
96
+ */
97
+ static create(config: FederatedSchemaConfig = {}) {
98
+ return new FederatedSchemaBuilder<never>({
99
+ coreBuilder: GraphQLSchemaBuilder.empty,
100
+ entities: new Map(),
101
+ version: config.version ?? "2.3",
102
+ })
103
+ }
104
+
105
+ /**
106
+ * Create a new builder with updated state
107
+ */
108
+ private with<R2>(updates: Partial<FederatedBuilderState<R2>>): FederatedSchemaBuilder<R | R2> {
109
+ return new FederatedSchemaBuilder({
110
+ ...this.state,
111
+ ...updates,
112
+ } as FederatedBuilderState<R | R2>)
113
+ }
114
+
115
+ /**
116
+ * Get the underlying core builder for advanced usage
117
+ */
118
+ get coreBuilder(): GraphQLSchemaBuilder<R> {
119
+ return this.state.coreBuilder
120
+ }
121
+
122
+ // ============================================================================
123
+ // Entity Registration
124
+ // ============================================================================
125
+
126
+ /**
127
+ * Register an entity type with @key directive(s) and reference resolver.
128
+ *
129
+ * Entities are the core building block of Apollo Federation. They represent
130
+ * types that can be resolved across subgraph boundaries using their key fields.
131
+ *
132
+ * @example
133
+ * ```typescript
134
+ * builder.entity({
135
+ * name: "User",
136
+ * schema: UserSchema,
137
+ * keys: [key({ fields: "id" })],
138
+ * resolveReference: (ref) => UserService.findById(ref.id),
139
+ * })
140
+ * ```
141
+ */
142
+ entity<A, R2>(config: EntityRegistration<A, R2>): FederatedSchemaBuilder<R | R2> {
143
+ const { name, schema, keys, directives } = config
144
+
145
+ // Build directive applications for the object type
146
+ const typeDirectives: DirectiveApplication[] = [
147
+ // Add @key directives
148
+ ...keys.map(
149
+ (k): DirectiveApplication => ({
150
+ name: "key",
151
+ args: {
152
+ fields: k.fields,
153
+ ...(k.resolvable !== undefined ? { resolvable: k.resolvable } : {}),
154
+ },
155
+ })
156
+ ),
157
+ // Add additional directives
158
+ ...(directives?.map(toDirectiveApplication) ?? []),
159
+ ]
160
+
161
+ // Register the entity as an object type with directives
162
+ const newCoreBuilder = this.state.coreBuilder.objectType({
163
+ name,
164
+ schema,
165
+ directives: typeDirectives,
166
+ })
167
+
168
+ // Add to entities map
169
+ const newEntities = new Map(this.state.entities)
170
+ newEntities.set(name, config)
171
+
172
+ return this.with({
173
+ coreBuilder: newCoreBuilder as GraphQLSchemaBuilder<any>,
174
+ entities: newEntities,
175
+ })
176
+ }
177
+
178
+ // ============================================================================
179
+ // Delegate to Core Builder
180
+ // ============================================================================
181
+
182
+ /**
183
+ * Add a query field
184
+ */
185
+ query<A, E, R2, Args = void>(
186
+ name: string,
187
+ config: {
188
+ type: S.Schema<A, any, any>
189
+ args?: S.Schema<Args, any, any>
190
+ description?: string
191
+ directives?: readonly DirectiveApplication[]
192
+ resolve: (args: Args) => Effect.Effect<A, E, R2>
193
+ }
194
+ ): FederatedSchemaBuilder<R | R2> {
195
+ return this.with({
196
+ coreBuilder: this.state.coreBuilder.query(name, config) as GraphQLSchemaBuilder<any>,
197
+ })
198
+ }
199
+
200
+ /**
201
+ * Add a mutation field
202
+ */
203
+ mutation<A, E, R2, Args = void>(
204
+ name: string,
205
+ config: {
206
+ type: S.Schema<A, any, any>
207
+ args?: S.Schema<Args, any, any>
208
+ description?: string
209
+ directives?: readonly DirectiveApplication[]
210
+ resolve: (args: Args) => Effect.Effect<A, E, R2>
211
+ }
212
+ ): FederatedSchemaBuilder<R | R2> {
213
+ return this.with({
214
+ coreBuilder: this.state.coreBuilder.mutation(name, config) as GraphQLSchemaBuilder<any>,
215
+ })
216
+ }
217
+
218
+ /**
219
+ * Add a subscription field
220
+ */
221
+ subscription<A, E, R2, Args = void>(
222
+ name: string,
223
+ config: {
224
+ type: S.Schema<A, any, any>
225
+ args?: S.Schema<Args, any, any>
226
+ description?: string
227
+ directives?: readonly DirectiveApplication[]
228
+ subscribe: (args: Args) => Effect.Effect<import("effect").Stream.Stream<A, E, R2>, E, R2>
229
+ resolve?: (value: A, args: Args) => Effect.Effect<A, E, R2>
230
+ }
231
+ ): FederatedSchemaBuilder<R | R2> {
232
+ return this.with({
233
+ coreBuilder: this.state.coreBuilder.subscription(name, config) as GraphQLSchemaBuilder<any>,
234
+ })
235
+ }
236
+
237
+ /**
238
+ * Register an object type (non-entity)
239
+ */
240
+ objectType<A, R2 = never>(config: {
241
+ name?: string
242
+ schema: S.Schema<A, any, any>
243
+ implements?: readonly string[]
244
+ directives?: readonly DirectiveApplication[]
245
+ }): FederatedSchemaBuilder<R | R2> {
246
+ return this.with({
247
+ coreBuilder: this.state.coreBuilder.objectType(config) as GraphQLSchemaBuilder<any>,
248
+ })
249
+ }
250
+
251
+ /**
252
+ * Register an interface type
253
+ */
254
+ interfaceType(config: {
255
+ name?: string
256
+ schema: S.Schema<any, any, any>
257
+ resolveType?: (value: any) => string
258
+ directives?: readonly DirectiveApplication[]
259
+ }): FederatedSchemaBuilder<R> {
260
+ return this.with({
261
+ coreBuilder: this.state.coreBuilder.interfaceType(config),
262
+ })
263
+ }
264
+
265
+ /**
266
+ * Register an enum type
267
+ */
268
+ enumType(config: {
269
+ name: string
270
+ values: readonly string[]
271
+ description?: string
272
+ directives?: readonly DirectiveApplication[]
273
+ }): FederatedSchemaBuilder<R> {
274
+ return this.with({
275
+ coreBuilder: this.state.coreBuilder.enumType(config),
276
+ })
277
+ }
278
+
279
+ /**
280
+ * Register a union type
281
+ */
282
+ unionType(config: {
283
+ name: string
284
+ types: readonly string[]
285
+ resolveType?: (value: any) => string
286
+ directives?: readonly DirectiveApplication[]
287
+ }): FederatedSchemaBuilder<R> {
288
+ return this.with({
289
+ coreBuilder: this.state.coreBuilder.unionType(config),
290
+ })
291
+ }
292
+
293
+ /**
294
+ * Register an input type
295
+ */
296
+ inputType(config: {
297
+ name?: string
298
+ schema: S.Schema<any, any, any>
299
+ description?: string
300
+ directives?: readonly DirectiveApplication[]
301
+ }): FederatedSchemaBuilder<R> {
302
+ return this.with({
303
+ coreBuilder: this.state.coreBuilder.inputType(config),
304
+ })
305
+ }
306
+
307
+ /**
308
+ * Add a computed/relational field to an object type
309
+ */
310
+ field<Parent, A, E, R2, Args = void>(
311
+ typeName: string,
312
+ fieldName: string,
313
+ config: {
314
+ type: S.Schema<A, any, any>
315
+ args?: S.Schema<Args, any, any>
316
+ description?: string
317
+ directives?: readonly DirectiveApplication[]
318
+ resolve: (parent: Parent, args: Args) => Effect.Effect<A, E, R2>
319
+ }
320
+ ): FederatedSchemaBuilder<R | R2> {
321
+ return this.with({
322
+ coreBuilder: this.state.coreBuilder.field(
323
+ typeName,
324
+ fieldName,
325
+ config
326
+ ) as GraphQLSchemaBuilder<any>,
327
+ })
328
+ }
329
+
330
+ // ============================================================================
331
+ // Schema Building
332
+ // ============================================================================
333
+
334
+ /**
335
+ * Build the federated GraphQL schema with _entities and _service queries.
336
+ *
337
+ * Returns both the executable schema and the Federation-compliant SDL.
338
+ */
339
+ buildFederatedSchema(): FederatedSchemaResult {
340
+ // Add a dummy query if no queries exist to ensure schema builds properly
341
+ // This ensures all registered types are included in the schema
342
+ let builderForSchema = this.state.coreBuilder
343
+
344
+ // Check if we need a placeholder query by attempting to build
345
+ // We need at least one query for GraphQL schema to be valid
346
+ const needsPlaceholder = !this.hasQueryFields()
347
+
348
+ if (needsPlaceholder) {
349
+ builderForSchema = builderForSchema.query("_placeholder", {
350
+ type: S.String,
351
+ resolve: () => Effect.succeed("placeholder"),
352
+ }) as GraphQLSchemaBuilder<any>
353
+ }
354
+
355
+ // Build the base schema with all types included
356
+ const baseSchema = builderForSchema.buildSchema()
357
+
358
+ // Get the type registry from the base schema
359
+ const typeRegistry = new Map<string, GraphQLObjectType>()
360
+ const typeMap = baseSchema.getTypeMap()
361
+
362
+ for (const [name, type] of Object.entries(typeMap)) {
363
+ // Use constructor name check instead of instanceof to handle multiple graphql instances
364
+ const isObjectType = type.constructor.name === "GraphQLObjectType"
365
+ if (isObjectType && !name.startsWith("__")) {
366
+ typeRegistry.set(name, type as GraphQLObjectType)
367
+ }
368
+ }
369
+
370
+ // Create federation types
371
+ const entityUnion =
372
+ this.state.entities.size > 0 ? createEntityUnion(this.state.entities, typeRegistry) : null
373
+ const serviceType = createServiceType()
374
+
375
+ // Build federation query fields
376
+ const federationQueryFields: Record<string, any> = {}
377
+
378
+ if (entityUnion) {
379
+ federationQueryFields._entities = {
380
+ type: new GraphQLNonNull(new GraphQLList(entityUnion)),
381
+ args: {
382
+ representations: {
383
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(AnyScalar))),
384
+ },
385
+ },
386
+ resolve: createEntitiesResolver(this.state.entities),
387
+ }
388
+ }
389
+
390
+ // Generate SDL before adding _service (to avoid circular reference)
391
+ const sdl = this.generateFederatedSDL(baseSchema, needsPlaceholder)
392
+
393
+ federationQueryFields._service = {
394
+ type: new GraphQLNonNull(serviceType),
395
+ resolve: createServiceResolver(sdl),
396
+ }
397
+
398
+ // Build the final schema by extending the base Query type
399
+ const baseQueryType = baseSchema.getQueryType()
400
+ const baseQueryFields = baseQueryType?.getFields() ?? {}
401
+
402
+ const queryType = new GraphQLObjectType({
403
+ name: "Query",
404
+ fields: () => {
405
+ const fields: Record<string, any> = {}
406
+
407
+ // Copy base query fields (excluding placeholder)
408
+ for (const [name, field] of Object.entries(baseQueryFields)) {
409
+ if (name === "_placeholder") continue
410
+ fields[name] = {
411
+ type: field.type,
412
+ args: field.args.reduce(
413
+ (acc, arg) => {
414
+ acc[arg.name] = {
415
+ type: arg.type,
416
+ description: arg.description,
417
+ defaultValue: arg.defaultValue,
418
+ }
419
+ return acc
420
+ },
421
+ {} as Record<string, any>
422
+ ),
423
+ description: field.description,
424
+ resolve: field.resolve,
425
+ extensions: field.extensions,
426
+ }
427
+ }
428
+
429
+ // Add federation fields
430
+ Object.assign(fields, federationQueryFields)
431
+
432
+ return fields
433
+ },
434
+ })
435
+
436
+ // Collect all types for the schema
437
+ const types: any[] = [AnyScalar, FieldSetScalar, serviceType]
438
+
439
+ if (entityUnion) {
440
+ types.push(entityUnion)
441
+ }
442
+
443
+ // Add all types from base schema except Query
444
+ for (const [name, type] of Object.entries(typeMap)) {
445
+ if (!name.startsWith("__") && name !== "Query") {
446
+ types.push(type)
447
+ }
448
+ }
449
+
450
+ const schema = new GraphQLSchema({
451
+ query: queryType,
452
+ mutation: baseSchema.getMutationType() ?? undefined,
453
+ subscription: baseSchema.getSubscriptionType() ?? undefined,
454
+ types,
455
+ })
456
+
457
+ return { schema, sdl }
458
+ }
459
+
460
+ /**
461
+ * Check if the core builder has any query fields registered
462
+ */
463
+ private hasQueryFields(): boolean {
464
+ // We need to check if there are any queries registered
465
+ // Since we can't access private state, we try building and check
466
+ try {
467
+ const schema = this.state.coreBuilder.buildSchema()
468
+ const queryType = schema.getQueryType()
469
+ return queryType !== null && queryType !== undefined
470
+ } catch {
471
+ return false
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Build a standard (non-federated) schema.
477
+ * Useful for testing or running without a gateway.
478
+ */
479
+ buildSchema(): GraphQLSchema {
480
+ return this.state.coreBuilder.buildSchema()
481
+ }
482
+
483
+ // ============================================================================
484
+ // SDL Generation
485
+ // ============================================================================
486
+
487
+ /**
488
+ * Generate Federation-compliant SDL with directive annotations.
489
+ */
490
+ private generateFederatedSDL(schema: GraphQLSchema, excludePlaceholder: boolean = false): string {
491
+ // Start with federation schema extension
492
+ const lines: string[] = [
493
+ `extend schema @link(url: "https://specs.apollo.dev/federation/v${this.state.version}", import: ["@key", "@shareable", "@external", "@requires", "@provides", "@override", "@inaccessible", "@interfaceObject", "@tag"])`,
494
+ "",
495
+ ]
496
+
497
+ // Print the base schema SDL
498
+ let baseSDL = printSchema(schema)
499
+
500
+ // Remove placeholder query if it was added
501
+ if (excludePlaceholder) {
502
+ baseSDL = baseSDL.replace(/\s*_placeholder:\s*String\n?/g, "")
503
+ }
504
+
505
+ // Process the SDL to add directive annotations
506
+ const annotatedSDL = this.annotateSDLWithDirectives(baseSDL, schema)
507
+
508
+ lines.push(annotatedSDL)
509
+
510
+ return lines.join("\n")
511
+ }
512
+
513
+ /**
514
+ * Annotate SDL types with their federation directives from extensions.
515
+ */
516
+ private annotateSDLWithDirectives(sdl: string, schema: GraphQLSchema): string {
517
+ const typeMap = schema.getTypeMap()
518
+ let result = sdl
519
+
520
+ for (const [typeName, type] of Object.entries(typeMap)) {
521
+ if (typeName.startsWith("__")) continue
522
+
523
+ const directives = (type.extensions as any)?.directives as DirectiveApplication[] | undefined
524
+ if (!directives || directives.length === 0) continue
525
+
526
+ const directiveStr = directives.map(formatDirective).join(" ")
527
+
528
+ // Match type definition and add directives
529
+ const typePattern = new RegExp(
530
+ `(type\\s+${typeName}(?:\\s+implements\\s+[^{]+)?)(\\s*\\{)`,
531
+ "g"
532
+ )
533
+ result = result.replace(typePattern, `$1 ${directiveStr}$2`)
534
+
535
+ const interfacePattern = new RegExp(`(interface\\s+${typeName})(\\s*\\{)`, "g")
536
+ result = result.replace(interfacePattern, `$1 ${directiveStr}$2`)
537
+
538
+ const enumPattern = new RegExp(`(enum\\s+${typeName})(\\s*\\{)`, "g")
539
+ result = result.replace(enumPattern, `$1 ${directiveStr}$2`)
540
+
541
+ const unionPattern = new RegExp(`(union\\s+${typeName})(\\s*=)`, "g")
542
+ result = result.replace(unionPattern, `$1 ${directiveStr}$2`)
543
+
544
+ const inputPattern = new RegExp(`(input\\s+${typeName})(\\s*\\{)`, "g")
545
+ result = result.replace(inputPattern, `$1 ${directiveStr}$2`)
546
+ }
547
+
548
+ // Also annotate fields with directives
549
+ for (const [typeName, type] of Object.entries(typeMap)) {
550
+ if (typeName.startsWith("__")) continue
551
+ if (!(type instanceof GraphQLObjectType)) continue
552
+
553
+ const fields = type.getFields()
554
+ for (const [fieldName, field] of Object.entries(fields)) {
555
+ const fieldDirectives = (field.extensions as any)?.directives as
556
+ | DirectiveApplication[]
557
+ | undefined
558
+ if (!fieldDirectives || fieldDirectives.length === 0) continue
559
+
560
+ const directiveStr = fieldDirectives.map(formatDirective).join(" ")
561
+
562
+ // Only replace within the context of this type
563
+ const typeBlockPattern = new RegExp(
564
+ `(type\\s+${typeName}[^{]*\\{[\\s\\S]*?)(${fieldName}(?:\\([^)]*\\))?:\\s*[^\\n]+?)([\\n}])`,
565
+ "g"
566
+ )
567
+ result = result.replace(typeBlockPattern, `$1$2 ${directiveStr}$3`)
568
+ }
569
+ }
570
+
571
+ return result
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Format a DirectiveApplication as SDL string
577
+ */
578
+ function formatDirective(directive: DirectiveApplication): string {
579
+ if (!directive.args || Object.keys(directive.args).length === 0) {
580
+ return `@${directive.name}`
581
+ }
582
+
583
+ const args = Object.entries(directive.args)
584
+ .map(([key, value]) => {
585
+ if (typeof value === "string") {
586
+ return `${key}: "${value}"`
587
+ }
588
+ return `${key}: ${JSON.stringify(value)}`
589
+ })
590
+ .join(", ")
591
+
592
+ return `@${directive.name}(${args})`
593
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Core builder
2
+ export { FederatedSchemaBuilder } from "./federated-builder"
3
+
4
+ // Types
5
+ export type {
6
+ KeyDirective,
7
+ FederationDirective,
8
+ EntityRepresentation,
9
+ EntityRegistration,
10
+ FederatedSchemaConfig,
11
+ FederatedSchemaResult,
12
+ } from "./types"
13
+ export { toDirectiveApplication } from "./types"
14
+
15
+ // Directive factories
16
+ export {
17
+ key,
18
+ shareable,
19
+ inaccessible,
20
+ interfaceObject,
21
+ tag,
22
+ external,
23
+ requires,
24
+ provides,
25
+ override,
26
+ } from "./directives"
27
+
28
+ // Pipe-able API
29
+ export {
30
+ entity,
31
+ query,
32
+ mutation,
33
+ subscription,
34
+ objectType,
35
+ interfaceType,
36
+ enumType,
37
+ unionType,
38
+ inputType,
39
+ field,
40
+ externalField,
41
+ requiresField,
42
+ providesField,
43
+ overrideField,
44
+ } from "./pipe-api"
45
+
46
+ // Federation scalars (for advanced usage)
47
+ export { AnyScalar, FieldSetScalar } from "./scalars"