Skip to content

Commit

Permalink
Create federation @Policy directive (#2818)
Browse files Browse the repository at this point in the history
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 <9501705+dariuszkuc@users.noreply.github.com>
Co-authored-by: Geoffroy Couprie <apollo@geoffroycouprie.com>
Co-authored-by: Trevor Scheer <trevor.scheer@gmail.com>
  • Loading branch information
4 people authored Nov 20, 2023
1 parent a40d9d0 commit e325b49
Show file tree
Hide file tree
Showing 22 changed files with 239 additions and 131 deletions.
35 changes: 35 additions & 0 deletions .changeset/khaki-rockets-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"apollo-federation-integration-testsuite": minor
"@apollo/query-planner": minor
"@apollo/query-graphs": minor
"@apollo/composition": minor
"@apollo/federation-internals": minor
"@apollo/gateway": minor
---

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"])
```

102 changes: 53 additions & 49 deletions composition-js/src/__tests__/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
printDirectiveDefinition,
printSchema,
printType,
RequiresScopesSpecDefinition,
} from '@apollo/federation-internals';
import { CompositionOptions, CompositionResult, composeServices } from '../compose';
import gql from 'graphql-tag';
Expand Down Expand Up @@ -4243,15 +4242,20 @@ describe('composition', () => {
});
});

describe('@requiresScopes', () => {
it('comprehensive locations', () => {
// @requiresScopes and @policy behave exactly the same way, and so all tests should be equally applicable to both directives
describe('@requiresScopes and @policy', () => {
const testsToRun = [
{ directiveName: '@requiresScopes', argName: 'scopes', argType: 'requiresScopes__Scope', fedType: 'federation__Scope', identity: 'https://specs.apollo.dev/requiresScopes' },
{ directiveName: '@policy', argName: 'policies', argType: 'policy__Policy', fedType: 'federation__Policy', identity: 'https://specs.apollo.dev/policy' },
]
it.each(testsToRun)('comprehensive locations', ({ directiveName, argName }) => {
const onObject = {
typeDefs: gql`
type Query {
object: ScopedObject!
}
type ScopedObject @requiresScopes(scopes: ["object"]) {
type ScopedObject ${directiveName}(${argName}: ["object"]) {
field: Int!
}
`,
Expand All @@ -4264,7 +4268,7 @@ describe('composition', () => {
interface: ScopedInterface!
}
interface ScopedInterface @requiresScopes(scopes: ["interface"]) {
interface ScopedInterface ${directiveName}(${argName}: ["interface"]) {
field: Int!
}
`,
Expand All @@ -4276,7 +4280,7 @@ describe('composition', () => {
type ScopedInterfaceObject
@interfaceObject
@key(fields: "id")
@requiresScopes(scopes: ["interfaceObject"])
${directiveName}(${argName}: ["interfaceObject"])
{
id: String!
}
Expand All @@ -4286,11 +4290,11 @@ describe('composition', () => {

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

const onEnum = {
typeDefs: gql`
enum ScopedEnum @requiresScopes(scopes: ["enum"]) {
enum ScopedEnum ${directiveName}(${argName}: ["enum"]) {
A
B
}
Expand All @@ -4312,7 +4316,7 @@ describe('composition', () => {
const onRootField = {
typeDefs: gql`
type Query {
scopedRootField: Int! @requiresScopes(scopes: ["rootField"])
scopedRootField: Int! ${directiveName}(${argName}: ["rootField"])
}
`,
name: 'on-root-field',
Expand All @@ -4325,7 +4329,7 @@ describe('composition', () => {
}
type ObjectWithScopedField {
field: Int! @requiresScopes(scopes: ["objectField"])
field: Int! ${directiveName}(${argName}: ["objectField"])
}
`,
name: 'on-object-field',
Expand All @@ -4339,7 +4343,7 @@ describe('composition', () => {
type EntityWithScopedField @key(fields: "id") {
id: ID!
field: Int! @requiresScopes(scopes: ["entityField"])
field: Int! ${directiveName}(${argName}: ["entityField"])
}
`,
name: 'on-entity-field',
Expand Down Expand Up @@ -4372,18 +4376,18 @@ describe('composition', () => {
expect(
result.schema
.elementByCoordinate(element)
?.hasAppliedDirective("requiresScopes")
?.hasAppliedDirective(directiveName.slice(1))
).toBeTruthy();
}
});

it('applies @requiresScopes on types as long as it is used once', () => {
it.each(testsToRun)('applies directive on types as long as it is used once', ({ directiveName, argName }) => {
const a1 = {
typeDefs: gql`
type Query {
a: A
}
type A @key(fields: "id") @requiresScopes(scopes: ["a"]) {
type A @key(fields: "id") ${directiveName}(${argName}: ["a"]) {
id: String!
a1: String
}
Expand All @@ -4407,18 +4411,18 @@ describe('composition', () => {
assertCompositionSuccess(result1);
assertCompositionSuccess(result2);

expect(result1.schema.type('A')?.hasAppliedDirective('requiresScopes')).toBeTruthy();
expect(result2.schema.type('A')?.hasAppliedDirective('requiresScopes')).toBeTruthy();
expect(result1.schema.type('A')?.hasAppliedDirective(directiveName.slice(1))).toBeTruthy();
expect(result2.schema.type('A')?.hasAppliedDirective(directiveName.slice(1))).toBeTruthy();
});

it('merges @requiresScopes lists (simple union)', () => {
it.each(testsToRun)('merges ${directiveName} lists (simple union)', ({ directiveName, argName }) => {
const a1 = {
typeDefs: gql`
type Query {
a: A
}
type A @requiresScopes(scopes: ["a"]) @key(fields: "id") {
type A ${directiveName}(${argName}: ["a"]) @key(fields: "id") {
id: String!
a1: String
}
Expand All @@ -4427,7 +4431,7 @@ describe('composition', () => {
};
const a2 = {
typeDefs: gql`
type A @requiresScopes(scopes: ["b"]) @key(fields: "id") {
type A ${directiveName}(${argName}: ["b"]) @key(fields: "id") {
id: String!
a2: String
}
Expand All @@ -4439,19 +4443,19 @@ describe('composition', () => {
assertCompositionSuccess(result);
expect(
result.schema.type('A')
?.appliedDirectivesOf('requiresScopes')
?.[0]?.arguments()?.scopes).toStrictEqual(['a', 'b']
?.appliedDirectivesOf(directiveName.slice(1))
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b']
);
});

it('merges @requiresScopes lists (deduplicates intersecting scopes)', () => {
it.each(testsToRun)('merges ${directiveName} lists (deduplicates intersecting scopes)', ({ directiveName, argName }) => {
const a1 = {
typeDefs: gql`
type Query {
a: A
}
type A @requiresScopes(scopes: ["a", "b"]) @key(fields: "id") {
type A ${directiveName}(${argName}: ["a", "b"]) @key(fields: "id") {
id: String!
a1: String
}
Expand All @@ -4460,7 +4464,7 @@ describe('composition', () => {
};
const a2 = {
typeDefs: gql`
type A @requiresScopes(scopes: ["b", "c"]) @key(fields: "id") {
type A ${directiveName}(${argName}: ["b", "c"]) @key(fields: "id") {
id: String!
a2: String
}
Expand All @@ -4472,37 +4476,37 @@ describe('composition', () => {
assertCompositionSuccess(result);
expect(
result.schema.type('A')
?.appliedDirectivesOf('requiresScopes')
?.[0]?.arguments()?.scopes).toStrictEqual(['a', 'b', 'c']
?.appliedDirectivesOf(directiveName.slice(1))
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b', 'c']
);
});

it('@requiresScopes has correct definition in the supergraph', () => {
it.each(testsToRun)('${directiveName} has correct definition in the supergraph', ({ directiveName, argName, argType, identity }) => {
const a = {
typeDefs: gql`
type Query {
x: Int @requiresScopes(scopes: ["a", "b"])
x: Int ${directiveName}(${argName}: ["a", "b"])
}
`,
name: 'a',
};

const result = composeAsFed2Subgraphs([a]);
assertCompositionSuccess(result);
expect(result.schema.coreFeatures?.getByIdentity(RequiresScopesSpecDefinition.identity)?.url.toString()).toBe(
"https://specs.apollo.dev/requiresScopes/v0.1"
expect(result.schema.coreFeatures?.getByIdentity(identity)?.url.toString()).toBe(
`https://specs.apollo.dev/${directiveName.slice(1)}/v0.1`
);
expect(printDirectiveDefinition(result.schema.directive('requiresScopes')!)).toMatchString(`
directive @requiresScopes(scopes: [[requiresScopes__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
expect(printDirectiveDefinition(result.schema.directive(directiveName.slice(1))!)).toMatchString(`
directive ${directiveName}(${argName}: [[${argType}!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
`);
});

it('composes with existing `Scope` scalar definitions in subgraphs', () => {
it.each(testsToRun)('composes with existing `Scope` scalar definitions in subgraphs', ({ directiveName, argName }) => {
const a = {
typeDefs: gql`
scalar Scope
type Query {
x: Int @requiresScopes(scopes: ["a", "b"])
x: Int ${directiveName}(${argName}: ["a", "b"])
}
`,
name: 'a',
Expand All @@ -4512,7 +4516,7 @@ describe('composition', () => {
typeDefs: gql`
scalar Scope @specifiedBy(url: "not-the-apollo-spec")
type Query {
y: Int @requiresScopes(scopes: ["a", "b"])
y: Int ${directiveName}(${argName}: ["a", "b"])
}
`,
name: 'b',
Expand All @@ -4523,69 +4527,69 @@ describe('composition', () => {
});

describe('validation errors', () => {
it('on incompatible directive location', () => {
it.each(testsToRun)('on incompatible directive location', ({ directiveName, argName, fedType }) => {
const invalidDefinition = {
typeDefs: gql`
scalar federation__Scope
directive @requiresScopes(scopes: [[federation__Scope!]!]!) on ENUM_VALUE
scalar ${fedType}
directive ${directiveName}(${argName}: [[${fedType}!]!]!) on ENUM_VALUE
type Query {
a: Int
}
enum E {
A @requiresScopes(scopes: [])
A ${directiveName}(${argName}: [])
}
`,
name: 'invalidDefinition',
};
const result = composeAsFed2Subgraphs([invalidDefinition]);
expect(errors(result)[0]).toEqual([
"DIRECTIVE_DEFINITION_INVALID",
"[invalidDefinition] Invalid definition for directive \"@requiresScopes\": \"@requiresScopes\" should have locations FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, ENUM, but found (non-subset) ENUM_VALUE",
`[invalidDefinition] Invalid definition for directive \"${directiveName}\": \"${directiveName}\" should have locations FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, ENUM, but found (non-subset) ENUM_VALUE`,
]);
});

it('on incompatible args', () => {
it.each(testsToRun)('on incompatible args', ({ directiveName, argName, fedType }) => {
const invalidDefinition = {
typeDefs: gql`
scalar federation__Scope
directive @requiresScopes(scopes: [federation__Scope]!) on FIELD_DEFINITION
scalar ${fedType}
directive ${directiveName}(${argName}: [${fedType}]!) on FIELD_DEFINITION
type Query {
a: Int
}
enum E {
A @requiresScopes(scopes: [])
A ${directiveName}(${argName}: [])
}
`,
name: 'invalidDefinition',
};
const result = composeAsFed2Subgraphs([invalidDefinition]);
expect(errors(result)[0]).toEqual([
"DIRECTIVE_DEFINITION_INVALID",
"[invalidDefinition] Invalid definition for directive \"@requiresScopes\": argument \"scopes\" should have type \"[[federation__Scope!]!]!\" but found type \"[federation__Scope]!\"",
`[invalidDefinition] Invalid definition for directive \"${directiveName}\": argument \"${argName}\" should have type \"[[${fedType}!]!]!\" but found type \"[${fedType}]!\"`,
]);
});

it('on invalid application', () => {
it.each(testsToRun)('on invalid application', ({ directiveName, argName }) => {
const invalidApplication = {
typeDefs: gql`
type Query {
a: Int
}
enum E {
A @requiresScopes(scopes: [])
A ${directiveName}(${argName}: [])
}
`,
name: 'invalidApplication',
};
const result = composeAsFed2Subgraphs([invalidApplication]);
expect(errors(result)[0]).toEqual([
"INVALID_GRAPHQL",
"[invalidApplication] Directive \"@requiresScopes\" may not be used on ENUM_VALUE.",
`[invalidApplication] Directive \"${directiveName}\" may not be used on ENUM_VALUE.`,
]);
});
});
Expand Down
1 change: 1 addition & 0 deletions composition-js/src/composeDirectiveManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export class ComposeDirectiveManager {
sg.metadata().inaccessibleDirective(),
sg.metadata().authenticatedDirective(),
sg.metadata().requiresScopesDirective(),
sg.metadata().policyDirective(),
].map(d => d.name);
if (directivesComposedByDefault.includes(directive.name)) {
this.pushHint(new CompositionHint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DirectiveLocation } from "graphql";
import "../definitions";
import { createDirectiveSpecification } from "../directiveAndTypeSpecification";
import { ARGUMENT_COMPOSITION_STRATEGIES } from "../argumentCompositionStrategies";
import { TAG_VERSIONS } from "../tagSpec";
import { TAG_VERSIONS } from "../specs/tagSpec";

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
UnionType,
} from "../definitions";
import { buildSchema } from "../buildSchema";
import { removeInaccessibleElements } from "../inaccessibleSpec";
import { removeInaccessibleElements } from "../specs/inaccessibleSpec";
import { GraphQLError } from "graphql";
import { errorCauses } from "../error";

Expand Down
Loading

0 comments on commit e325b49

Please sign in to comment.