Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create federation @policy directive #2818

Merged
merged 8 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 ([#2818](https://github.com/apollographql/federation/pull/2818))
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved

> 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
Loading