Skip to content

Commit e325b49

Browse files
clenfestdariuszkucGealtrevor-scheer
authored
Create federation @Policy directive (#2818)
Introduce the new `@policy` scope for composition > Note that this directive will only be _fully_ supported by the Apollo Router as a GraphOS Enterprise feature at runtime. Also note that _composition_ of valid `@policy` directive applications will succeed, but the resulting supergraph will not be _executable_ by the Gateway or an Apollo Router which doesn't have the GraphOS Enterprise entitlement. Users may now compose `@policy` applications from their subgraphs into a supergraph. The directive is defined as follows: ```graphql scalar federation__Policy directive @Policy(policies: [[federation__Policy!]!]!) on | FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM ``` The `Policy` scalar is effectively a `String`, similar to the `FieldSet` type. In order to compose your `@policy` usages, you must update your subgraph's federation spec version to v2.6 and add the `@policy` import to your existing imports like so: ```graphql @link(url: "https://specs.apollo.dev/federation/v2.6", import: [..., "@Policy"]) ``` For additional context, this PR effectively follows the pattern implemented by #2644 --------- Co-authored-by: Dariusz Kuc <[email protected]> Co-authored-by: Geoffroy Couprie <[email protected]> Co-authored-by: Trevor Scheer <[email protected]>
1 parent a40d9d0 commit e325b49

22 files changed

+239
-131
lines changed

.changeset/khaki-rockets-complain.md

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"apollo-federation-integration-testsuite": minor
3+
"@apollo/query-planner": minor
4+
"@apollo/query-graphs": minor
5+
"@apollo/composition": minor
6+
"@apollo/federation-internals": minor
7+
"@apollo/gateway": minor
8+
---
9+
10+
Introduce the new `@policy` scope for composition
11+
12+
> Note that this directive will only be _fully_ supported by the Apollo Router as a GraphOS Enterprise feature at runtime. Also note that _composition_ of valid `@policy` directive applications will succeed, but the resulting supergraph will not be _executable_ by the Gateway or an Apollo Router which doesn't have the GraphOS Enterprise entitlement.
13+
14+
Users may now compose `@policy` applications from their subgraphs into a supergraph.
15+
16+
The directive is defined as follows:
17+
18+
```graphql
19+
scalar federation__Policy
20+
21+
directive @policy(policies: [[federation__Policy!]!]!) on
22+
| FIELD_DEFINITION
23+
| OBJECT
24+
| INTERFACE
25+
| SCALAR
26+
| ENUM
27+
```
28+
29+
The `Policy` scalar is effectively a `String`, similar to the `FieldSet` type.
30+
31+
In order to compose your `@policy` usages, you must update your subgraph's federation spec version to v2.6 and add the `@policy` import to your existing imports like so:
32+
```graphql
33+
@link(url: "https://specs.apollo.dev/federation/v2.6", import: [..., "@policy"])
34+
```
35+

composition-js/src/__tests__/compose.test.ts

+53-49
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
printDirectiveDefinition,
1414
printSchema,
1515
printType,
16-
RequiresScopesSpecDefinition,
1716
} from '@apollo/federation-internals';
1817
import { CompositionOptions, CompositionResult, composeServices } from '../compose';
1918
import gql from 'graphql-tag';
@@ -4243,15 +4242,20 @@ describe('composition', () => {
42434242
});
42444243
});
42454244

4246-
describe('@requiresScopes', () => {
4247-
it('comprehensive locations', () => {
4245+
// @requiresScopes and @policy behave exactly the same way, and so all tests should be equally applicable to both directives
4246+
describe('@requiresScopes and @policy', () => {
4247+
const testsToRun = [
4248+
{ directiveName: '@requiresScopes', argName: 'scopes', argType: 'requiresScopes__Scope', fedType: 'federation__Scope', identity: 'https://specs.apollo.dev/requiresScopes' },
4249+
{ directiveName: '@policy', argName: 'policies', argType: 'policy__Policy', fedType: 'federation__Policy', identity: 'https://specs.apollo.dev/policy' },
4250+
]
4251+
it.each(testsToRun)('comprehensive locations', ({ directiveName, argName }) => {
42484252
const onObject = {
42494253
typeDefs: gql`
42504254
type Query {
42514255
object: ScopedObject!
42524256
}
42534257
4254-
type ScopedObject @requiresScopes(scopes: ["object"]) {
4258+
type ScopedObject ${directiveName}(${argName}: ["object"]) {
42554259
field: Int!
42564260
}
42574261
`,
@@ -4264,7 +4268,7 @@ describe('composition', () => {
42644268
interface: ScopedInterface!
42654269
}
42664270
4267-
interface ScopedInterface @requiresScopes(scopes: ["interface"]) {
4271+
interface ScopedInterface ${directiveName}(${argName}: ["interface"]) {
42684272
field: Int!
42694273
}
42704274
`,
@@ -4276,7 +4280,7 @@ describe('composition', () => {
42764280
type ScopedInterfaceObject
42774281
@interfaceObject
42784282
@key(fields: "id")
4279-
@requiresScopes(scopes: ["interfaceObject"])
4283+
${directiveName}(${argName}: ["interfaceObject"])
42804284
{
42814285
id: String!
42824286
}
@@ -4286,11 +4290,11 @@ describe('composition', () => {
42864290

42874291
const onScalar = {
42884292
typeDefs: gql`
4289-
scalar ScopedScalar @requiresScopes(scopes: ["scalar"])
4293+
scalar ScopedScalar ${directiveName}(${argName}: ["scalar"])
42904294
42914295
# This needs to exist in at least one other subgraph from where it's defined
42924296
# as an @interfaceObject (so arbitrarily adding it here). We don't actually
4293-
# apply @requiresScopes to this one since we want to see it propagate even
4297+
# apply ${directiveName} to this one since we want to see it propagate even
42944298
# when it's not applied in all locations.
42954299
interface ScopedInterfaceObject @key(fields: "id") {
42964300
id: String!
@@ -4301,7 +4305,7 @@ describe('composition', () => {
43014305

43024306
const onEnum = {
43034307
typeDefs: gql`
4304-
enum ScopedEnum @requiresScopes(scopes: ["enum"]) {
4308+
enum ScopedEnum ${directiveName}(${argName}: ["enum"]) {
43054309
A
43064310
B
43074311
}
@@ -4312,7 +4316,7 @@ describe('composition', () => {
43124316
const onRootField = {
43134317
typeDefs: gql`
43144318
type Query {
4315-
scopedRootField: Int! @requiresScopes(scopes: ["rootField"])
4319+
scopedRootField: Int! ${directiveName}(${argName}: ["rootField"])
43164320
}
43174321
`,
43184322
name: 'on-root-field',
@@ -4325,7 +4329,7 @@ describe('composition', () => {
43254329
}
43264330
43274331
type ObjectWithScopedField {
4328-
field: Int! @requiresScopes(scopes: ["objectField"])
4332+
field: Int! ${directiveName}(${argName}: ["objectField"])
43294333
}
43304334
`,
43314335
name: 'on-object-field',
@@ -4339,7 +4343,7 @@ describe('composition', () => {
43394343
43404344
type EntityWithScopedField @key(fields: "id") {
43414345
id: ID!
4342-
field: Int! @requiresScopes(scopes: ["entityField"])
4346+
field: Int! ${directiveName}(${argName}: ["entityField"])
43434347
}
43444348
`,
43454349
name: 'on-entity-field',
@@ -4372,18 +4376,18 @@ describe('composition', () => {
43724376
expect(
43734377
result.schema
43744378
.elementByCoordinate(element)
4375-
?.hasAppliedDirective("requiresScopes")
4379+
?.hasAppliedDirective(directiveName.slice(1))
43764380
).toBeTruthy();
43774381
}
43784382
});
43794383

4380-
it('applies @requiresScopes on types as long as it is used once', () => {
4384+
it.each(testsToRun)('applies directive on types as long as it is used once', ({ directiveName, argName }) => {
43814385
const a1 = {
43824386
typeDefs: gql`
43834387
type Query {
43844388
a: A
43854389
}
4386-
type A @key(fields: "id") @requiresScopes(scopes: ["a"]) {
4390+
type A @key(fields: "id") ${directiveName}(${argName}: ["a"]) {
43874391
id: String!
43884392
a1: String
43894393
}
@@ -4407,18 +4411,18 @@ describe('composition', () => {
44074411
assertCompositionSuccess(result1);
44084412
assertCompositionSuccess(result2);
44094413

4410-
expect(result1.schema.type('A')?.hasAppliedDirective('requiresScopes')).toBeTruthy();
4411-
expect(result2.schema.type('A')?.hasAppliedDirective('requiresScopes')).toBeTruthy();
4414+
expect(result1.schema.type('A')?.hasAppliedDirective(directiveName.slice(1))).toBeTruthy();
4415+
expect(result2.schema.type('A')?.hasAppliedDirective(directiveName.slice(1))).toBeTruthy();
44124416
});
44134417

4414-
it('merges @requiresScopes lists (simple union)', () => {
4418+
it.each(testsToRun)('merges ${directiveName} lists (simple union)', ({ directiveName, argName }) => {
44154419
const a1 = {
44164420
typeDefs: gql`
44174421
type Query {
44184422
a: A
44194423
}
44204424
4421-
type A @requiresScopes(scopes: ["a"]) @key(fields: "id") {
4425+
type A ${directiveName}(${argName}: ["a"]) @key(fields: "id") {
44224426
id: String!
44234427
a1: String
44244428
}
@@ -4427,7 +4431,7 @@ describe('composition', () => {
44274431
};
44284432
const a2 = {
44294433
typeDefs: gql`
4430-
type A @requiresScopes(scopes: ["b"]) @key(fields: "id") {
4434+
type A ${directiveName}(${argName}: ["b"]) @key(fields: "id") {
44314435
id: String!
44324436
a2: String
44334437
}
@@ -4439,19 +4443,19 @@ describe('composition', () => {
44394443
assertCompositionSuccess(result);
44404444
expect(
44414445
result.schema.type('A')
4442-
?.appliedDirectivesOf('requiresScopes')
4443-
?.[0]?.arguments()?.scopes).toStrictEqual(['a', 'b']
4446+
?.appliedDirectivesOf(directiveName.slice(1))
4447+
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b']
44444448
);
44454449
});
44464450

4447-
it('merges @requiresScopes lists (deduplicates intersecting scopes)', () => {
4451+
it.each(testsToRun)('merges ${directiveName} lists (deduplicates intersecting scopes)', ({ directiveName, argName }) => {
44484452
const a1 = {
44494453
typeDefs: gql`
44504454
type Query {
44514455
a: A
44524456
}
44534457
4454-
type A @requiresScopes(scopes: ["a", "b"]) @key(fields: "id") {
4458+
type A ${directiveName}(${argName}: ["a", "b"]) @key(fields: "id") {
44554459
id: String!
44564460
a1: String
44574461
}
@@ -4460,7 +4464,7 @@ describe('composition', () => {
44604464
};
44614465
const a2 = {
44624466
typeDefs: gql`
4463-
type A @requiresScopes(scopes: ["b", "c"]) @key(fields: "id") {
4467+
type A ${directiveName}(${argName}: ["b", "c"]) @key(fields: "id") {
44644468
id: String!
44654469
a2: String
44664470
}
@@ -4472,37 +4476,37 @@ describe('composition', () => {
44724476
assertCompositionSuccess(result);
44734477
expect(
44744478
result.schema.type('A')
4475-
?.appliedDirectivesOf('requiresScopes')
4476-
?.[0]?.arguments()?.scopes).toStrictEqual(['a', 'b', 'c']
4479+
?.appliedDirectivesOf(directiveName.slice(1))
4480+
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b', 'c']
44774481
);
44784482
});
44794483

4480-
it('@requiresScopes has correct definition in the supergraph', () => {
4484+
it.each(testsToRun)('${directiveName} has correct definition in the supergraph', ({ directiveName, argName, argType, identity }) => {
44814485
const a = {
44824486
typeDefs: gql`
44834487
type Query {
4484-
x: Int @requiresScopes(scopes: ["a", "b"])
4488+
x: Int ${directiveName}(${argName}: ["a", "b"])
44854489
}
44864490
`,
44874491
name: 'a',
44884492
};
44894493

44904494
const result = composeAsFed2Subgraphs([a]);
44914495
assertCompositionSuccess(result);
4492-
expect(result.schema.coreFeatures?.getByIdentity(RequiresScopesSpecDefinition.identity)?.url.toString()).toBe(
4493-
"https://specs.apollo.dev/requiresScopes/v0.1"
4496+
expect(result.schema.coreFeatures?.getByIdentity(identity)?.url.toString()).toBe(
4497+
`https://specs.apollo.dev/${directiveName.slice(1)}/v0.1`
44944498
);
4495-
expect(printDirectiveDefinition(result.schema.directive('requiresScopes')!)).toMatchString(`
4496-
directive @requiresScopes(scopes: [[requiresScopes__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
4499+
expect(printDirectiveDefinition(result.schema.directive(directiveName.slice(1))!)).toMatchString(`
4500+
directive ${directiveName}(${argName}: [[${argType}!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
44974501
`);
44984502
});
44994503

4500-
it('composes with existing `Scope` scalar definitions in subgraphs', () => {
4504+
it.each(testsToRun)('composes with existing `Scope` scalar definitions in subgraphs', ({ directiveName, argName }) => {
45014505
const a = {
45024506
typeDefs: gql`
45034507
scalar Scope
45044508
type Query {
4505-
x: Int @requiresScopes(scopes: ["a", "b"])
4509+
x: Int ${directiveName}(${argName}: ["a", "b"])
45064510
}
45074511
`,
45084512
name: 'a',
@@ -4512,7 +4516,7 @@ describe('composition', () => {
45124516
typeDefs: gql`
45134517
scalar Scope @specifiedBy(url: "not-the-apollo-spec")
45144518
type Query {
4515-
y: Int @requiresScopes(scopes: ["a", "b"])
4519+
y: Int ${directiveName}(${argName}: ["a", "b"])
45164520
}
45174521
`,
45184522
name: 'b',
@@ -4523,69 +4527,69 @@ describe('composition', () => {
45234527
});
45244528

45254529
describe('validation errors', () => {
4526-
it('on incompatible directive location', () => {
4530+
it.each(testsToRun)('on incompatible directive location', ({ directiveName, argName, fedType }) => {
45274531
const invalidDefinition = {
45284532
typeDefs: gql`
4529-
scalar federation__Scope
4530-
directive @requiresScopes(scopes: [[federation__Scope!]!]!) on ENUM_VALUE
4533+
scalar ${fedType}
4534+
directive ${directiveName}(${argName}: [[${fedType}!]!]!) on ENUM_VALUE
45314535
45324536
type Query {
45334537
a: Int
45344538
}
45354539
45364540
enum E {
4537-
A @requiresScopes(scopes: [])
4541+
A ${directiveName}(${argName}: [])
45384542
}
45394543
`,
45404544
name: 'invalidDefinition',
45414545
};
45424546
const result = composeAsFed2Subgraphs([invalidDefinition]);
45434547
expect(errors(result)[0]).toEqual([
45444548
"DIRECTIVE_DEFINITION_INVALID",
4545-
"[invalidDefinition] Invalid definition for directive \"@requiresScopes\": \"@requiresScopes\" should have locations FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, ENUM, but found (non-subset) ENUM_VALUE",
4549+
`[invalidDefinition] Invalid definition for directive \"${directiveName}\": \"${directiveName}\" should have locations FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, ENUM, but found (non-subset) ENUM_VALUE`,
45464550
]);
45474551
});
45484552

4549-
it('on incompatible args', () => {
4553+
it.each(testsToRun)('on incompatible args', ({ directiveName, argName, fedType }) => {
45504554
const invalidDefinition = {
45514555
typeDefs: gql`
4552-
scalar federation__Scope
4553-
directive @requiresScopes(scopes: [federation__Scope]!) on FIELD_DEFINITION
4556+
scalar ${fedType}
4557+
directive ${directiveName}(${argName}: [${fedType}]!) on FIELD_DEFINITION
45544558
45554559
type Query {
45564560
a: Int
45574561
}
45584562
45594563
enum E {
4560-
A @requiresScopes(scopes: [])
4564+
A ${directiveName}(${argName}: [])
45614565
}
45624566
`,
45634567
name: 'invalidDefinition',
45644568
};
45654569
const result = composeAsFed2Subgraphs([invalidDefinition]);
45664570
expect(errors(result)[0]).toEqual([
45674571
"DIRECTIVE_DEFINITION_INVALID",
4568-
"[invalidDefinition] Invalid definition for directive \"@requiresScopes\": argument \"scopes\" should have type \"[[federation__Scope!]!]!\" but found type \"[federation__Scope]!\"",
4572+
`[invalidDefinition] Invalid definition for directive \"${directiveName}\": argument \"${argName}\" should have type \"[[${fedType}!]!]!\" but found type \"[${fedType}]!\"`,
45694573
]);
45704574
});
45714575

4572-
it('on invalid application', () => {
4576+
it.each(testsToRun)('on invalid application', ({ directiveName, argName }) => {
45734577
const invalidApplication = {
45744578
typeDefs: gql`
45754579
type Query {
45764580
a: Int
45774581
}
45784582
45794583
enum E {
4580-
A @requiresScopes(scopes: [])
4584+
A ${directiveName}(${argName}: [])
45814585
}
45824586
`,
45834587
name: 'invalidApplication',
45844588
};
45854589
const result = composeAsFed2Subgraphs([invalidApplication]);
45864590
expect(errors(result)[0]).toEqual([
45874591
"INVALID_GRAPHQL",
4588-
"[invalidApplication] Directive \"@requiresScopes\" may not be used on ENUM_VALUE.",
4592+
`[invalidApplication] Directive \"${directiveName}\" may not be used on ENUM_VALUE.`,
45894593
]);
45904594
});
45914595
});

composition-js/src/composeDirectiveManager.ts

+1
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export class ComposeDirectiveManager {
174174
sg.metadata().inaccessibleDirective(),
175175
sg.metadata().authenticatedDirective(),
176176
sg.metadata().requiresScopesDirective(),
177+
sg.metadata().policyDirective(),
177178
].map(d => d.name);
178179
if (directivesComposedByDefault.includes(directive.name)) {
179180
this.pushHint(new CompositionHint(

internals-js/src/__tests__/directiveAndTypeSpecifications.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DirectiveLocation } from "graphql";
22
import "../definitions";
33
import { createDirectiveSpecification } from "../directiveAndTypeSpecification";
44
import { ARGUMENT_COMPOSITION_STRATEGIES } from "../argumentCompositionStrategies";
5-
import { TAG_VERSIONS } from "../tagSpec";
5+
import { TAG_VERSIONS } from "../specs/tagSpec";
66

77
const supergraphSpecification = () => TAG_VERSIONS.latest();
88

internals-js/src/__tests__/removeInaccessibleElements.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
UnionType,
77
} from "../definitions";
88
import { buildSchema } from "../buildSchema";
9-
import { removeInaccessibleElements } from "../inaccessibleSpec";
9+
import { removeInaccessibleElements } from "../specs/inaccessibleSpec";
1010
import { GraphQLError } from "graphql";
1111
import { errorCauses } from "../error";
1212

0 commit comments

Comments
 (0)