Skip to content

Commit 6b547dd

Browse files
authored
Support authentication on root custom resolver fields (#4816)
1 parent 720a5ee commit 6b547dd

File tree

13 files changed

+689
-69
lines changed

13 files changed

+689
-69
lines changed

.changeset/chilly-panthers-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@neo4j/graphql": minor
3+
---
4+
5+
Adds support for the `@authentication` directive on custom resolved fields of root types Query and Mutation

packages/graphql/src/classes/Neo4jGraphQL.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { getDefinitionNodes } from "../schema/get-definition-nodes";
3333
import { makeDocumentToAugment } from "../schema/make-document-to-augment";
3434
import type { WrapResolverArguments } from "../schema/resolvers/composition/wrap-query-and-mutation";
3535
import { wrapQueryAndMutation } from "../schema/resolvers/composition/wrap-query-and-mutation";
36-
import { wrapSubscription } from "../schema/resolvers/composition/wrap-subscription";
36+
import { wrapSubscription, type WrapSubscriptionArgs } from "../schema/resolvers/composition/wrap-subscription";
3737
import { defaultFieldResolver } from "../schema/resolvers/field/defaultField";
3838
import { validateDocument } from "../schema/validation";
3939
import { validateUserDefinition } from "../schema/validation/schema-validation";
@@ -50,6 +50,7 @@ import { Neo4jGraphQLSubscriptionsDefaultEngine } from "./subscription/Neo4jGrap
5050
import type { AssertIndexesAndConstraintsOptions } from "./utils/asserts-indexes-and-constraints";
5151
import { assertIndexesAndConstraints } from "./utils/asserts-indexes-and-constraints";
5252
import checkNeo4jCompat from "./utils/verify-database";
53+
import { generateResolverComposition } from "./utils/generate-resolvers-composition";
5354

5455
type TypeDefinitions = string | DocumentNode | TypeDefinitions[] | (() => TypeDefinitions);
5556

@@ -283,22 +284,28 @@ class Neo4jGraphQL {
283284
authorization: this.authorization,
284285
jwtPayloadFieldsMap: this.jwtFieldsMap,
285286
};
287+
const queryAndMutationWrappers = [wrapQueryAndMutation(wrapResolverArgs)];
286288

287-
const resolversComposition = {
288-
"Query.*": [wrapQueryAndMutation(wrapResolverArgs)],
289-
"Mutation.*": [wrapQueryAndMutation(wrapResolverArgs)],
289+
const isSubscriptionEnabled = !!this.features.subscriptions;
290+
const wrapSubscriptionResolverArgs = {
291+
subscriptionsEngine: this.features.subscriptions,
292+
schemaModel: this.schemaModel,
293+
authorization: this.authorization,
294+
jwtPayloadFieldsMap: this.jwtFieldsMap,
290295
};
296+
const subscriptionWrappers = isSubscriptionEnabled
297+
? [wrapSubscription(wrapSubscriptionResolverArgs as WrapSubscriptionArgs)]
298+
: [];
291299

292-
if (this.features.subscriptions) {
293-
resolversComposition["Subscription.*"] = wrapSubscription({
294-
subscriptionsEngine: this.features.subscriptions,
295-
schemaModel: this.schemaModel,
296-
authorization: this.authorization,
297-
jwtPayloadFieldsMap: this.jwtFieldsMap,
298-
});
299-
}
300+
const resolversComposition = generateResolverComposition({
301+
schemaModel: this.schemaModel,
302+
isSubscriptionEnabled,
303+
queryAndMutationWrappers,
304+
subscriptionWrappers,
305+
});
300306

301307
// Merge generated and custom resolvers
308+
// Merging must be done before composing because wrapper won't run otherwise
302309
const mergedResolvers = mergeResolvers([...asArray(resolvers), ...asArray(this.resolvers)]);
303310
return composeResolvers(mergedResolvers, resolversComposition);
304311
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright (c) "Neo4j"
3+
* Neo4j Sweden AB [http://neo4j.com]
4+
*
5+
* This file is part of Neo4j.
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
20+
import type { ResolversComposerMapping } from "@graphql-tools/resolvers-composition";
21+
import type { IResolvers } from "@graphql-tools/utils";
22+
import type { GraphQLResolveInfo } from "graphql";
23+
import type { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel";
24+
import { isAuthenticated } from "../../translate/authorization/check-authentication";
25+
26+
export function generateResolverComposition({
27+
schemaModel,
28+
isSubscriptionEnabled,
29+
queryAndMutationWrappers,
30+
subscriptionWrappers,
31+
}: {
32+
schemaModel: Neo4jGraphQLSchemaModel;
33+
isSubscriptionEnabled: boolean;
34+
queryAndMutationWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[];
35+
subscriptionWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[];
36+
}): ResolversComposerMapping<IResolvers<any, GraphQLResolveInfo, Record<string, any>, any>> {
37+
const resolverComposition = {};
38+
const {
39+
userCustomResolverPattern: customResolverQueryPattern,
40+
generatedResolverPattern: generatedResolverQueryPattern,
41+
} = getPathMatcherForRootType("Query", schemaModel);
42+
resolverComposition[`Query.${customResolverQueryPattern}`] = [
43+
...queryAndMutationWrappers,
44+
isAuthenticated(["READ"], schemaModel.operations.Query),
45+
];
46+
resolverComposition[`Query.${generatedResolverQueryPattern}`] = queryAndMutationWrappers;
47+
48+
const {
49+
userCustomResolverPattern: customResolverMutationPattern,
50+
generatedResolverPattern: generatedResolverMutationPattern,
51+
} = getPathMatcherForRootType("Mutation", schemaModel);
52+
resolverComposition[`Mutation.${customResolverMutationPattern}`] = [
53+
...queryAndMutationWrappers,
54+
isAuthenticated(["CREATE", "UPDATE", "DELETE"], schemaModel.operations.Mutation),
55+
];
56+
resolverComposition[`Mutation.${generatedResolverMutationPattern}`] = queryAndMutationWrappers;
57+
58+
if (isSubscriptionEnabled) {
59+
resolverComposition["Subscription.*"] = subscriptionWrappers;
60+
}
61+
return resolverComposition;
62+
}
63+
64+
function getPathMatcherForRootType(
65+
rootType: "Query" | "Mutation",
66+
schemaModel: Neo4jGraphQLSchemaModel
67+
): {
68+
userCustomResolverPattern: string;
69+
generatedResolverPattern: string;
70+
} {
71+
const operation = schemaModel.operations[rootType];
72+
if (!operation) {
73+
return { userCustomResolverPattern: "*", generatedResolverPattern: "*" };
74+
}
75+
const userDefinedFields = Array.from(operation.userResolvedAttributes.keys());
76+
if (!userDefinedFields.length) {
77+
return { userCustomResolverPattern: "*", generatedResolverPattern: "*" };
78+
}
79+
const userCustomResolverPattern = `{${userDefinedFields.join(", ")}}`;
80+
return { userCustomResolverPattern, generatedResolverPattern: `!${userCustomResolverPattern}` };
81+
}

packages/graphql/src/schema-model/Operation.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,18 @@ export class Operation {
2626
public readonly name: string;
2727
// only includes custom Cypher fields
2828
public readonly attributes: Map<string, Attribute> = new Map();
29+
public readonly userResolvedAttributes: Map<string, Attribute> = new Map();
2930
public readonly annotations: Partial<Annotations>;
3031

3132
constructor({
3233
name,
3334
attributes = [],
35+
userResolvedAttributes = [],
3436
annotations = {},
3537
}: {
3638
name: string;
3739
attributes?: Attribute[];
40+
userResolvedAttributes?: Attribute[];
3841
annotations?: Partial<Annotations>;
3942
}) {
4043
this.name = name;
@@ -43,16 +46,32 @@ export class Operation {
4346
for (const attribute of attributes) {
4447
this.addAttribute(attribute);
4548
}
49+
for (const attribute of userResolvedAttributes) {
50+
this.addUserResolvedAttributes(attribute);
51+
}
4652
}
4753

4854
public findAttribute(name: string): Attribute | undefined {
4955
return this.attributes.get(name);
5056
}
5157

58+
public findUserResolvedAttributes(name: string): Attribute | undefined {
59+
return this.userResolvedAttributes.get(name);
60+
}
61+
5262
private addAttribute(attribute: Attribute): void {
5363
if (this.attributes.has(attribute.name)) {
5464
throw new Neo4jGraphQLSchemaValidationError(`Attribute ${attribute.name} already exists in ${this.name}`);
5565
}
5666
this.attributes.set(attribute.name, attribute);
5767
}
68+
69+
private addUserResolvedAttributes(attribute: Attribute): void {
70+
if (this.userResolvedAttributes.has(attribute.name)) {
71+
throw new Neo4jGraphQLSchemaValidationError(
72+
`User Resolved Attribute ${attribute.name} already exists in ${this.name}`
73+
);
74+
}
75+
this.userResolvedAttributes.set(attribute.name, attribute);
76+
}
5877
}

packages/graphql/src/schema-model/OperationAdapter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ import type { Operation } from "./Operation";
2525
export class OperationAdapter {
2626
public readonly name: string;
2727
public readonly attributes: Map<string, AttributeAdapter> = new Map();
28+
public readonly userResolvedAttributes: Map<string, AttributeAdapter> = new Map();
2829
public readonly annotations: Partial<Annotations>;
2930

3031
constructor(entity: Operation) {
3132
this.name = entity.name;
3233
this.initAttributes(entity.attributes);
34+
this.initUserResolvedAttributes(entity.userResolvedAttributes);
3335
this.annotations = entity.annotations;
3436
}
3537

@@ -39,6 +41,12 @@ export class OperationAdapter {
3941
this.attributes.set(attributeName, attributeAdapter);
4042
}
4143
}
44+
private initUserResolvedAttributes(attributes: Map<string, Attribute>) {
45+
for (const [attributeName, attribute] of attributes.entries()) {
46+
const attributeAdapter = new AttributeAdapter(attribute);
47+
this.userResolvedAttributes.set(attributeName, attributeAdapter);
48+
}
49+
}
4250

4351
public get objectFields(): AttributeAdapter[] {
4452
return Array.from(this.attributes.values()).filter((attribute) => attribute.isObjectField());

packages/graphql/src/schema-model/generate-model.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,13 +543,23 @@ function generateOperation(
543543
definition: ObjectTypeDefinitionNode,
544544
definitionCollection: DefinitionCollection
545545
): Operation {
546-
const attributes = (definition.fields || [])
546+
const { attributes, userResolvedAttributes } = (definition.fields || [])
547547
.map((fieldDefinition) => parseAttribute(fieldDefinition, definitionCollection))
548-
.filter((attribute) => attribute.annotations.cypher);
549-
548+
.reduce<{ attributes: Attribute[]; userResolvedAttributes: Attribute[] }>(
549+
(acc, attribute) => {
550+
if (attribute.annotations.cypher) {
551+
acc.attributes.push(attribute);
552+
} else {
553+
acc.userResolvedAttributes.push(attribute);
554+
}
555+
return acc;
556+
},
557+
{ attributes: [], userResolvedAttributes: [] }
558+
);
550559
return new Operation({
551560
name: definition.name.value,
552561
attributes,
562+
userResolvedAttributes,
553563
annotations: parseAnnotations(definition.directives || []),
554564
});
555565
}

packages/graphql/src/schema/make-augmented-schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,16 @@ function makeAugmentedSchema({
347347

348348
objectComposer.addFields({ [attributeAdapter.name]: { ...composedField, ...customResolver } });
349349
}
350+
351+
// this is to remove library directives from custom resolvers on root type fields in augmented schema
352+
for (const attributeAdapter of operationAdapter.userResolvedAttributes.values()) {
353+
const composedField = attributeAdapterToComposeFields([attributeAdapter], userDefinedFieldDirectives)[
354+
attributeAdapter.name
355+
];
356+
if (composedField) {
357+
objectComposer.addFields({ [attributeAdapter.name]: composedField });
358+
}
359+
}
350360
});
351361

352362
if (!Object.values(composer.Mutation.getFields()).length) {

packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function noDirectivesAllowedAtLocation({
145145
}
146146
}
147147

148-
/** only the @cypher directive is valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */
148+
/** only the @cypher and @authentication directives are valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */
149149
function validFieldOfRootTypeLocation({
150150
directiveNode,
151151
traversedDef,
@@ -161,20 +161,14 @@ function validFieldOfRootTypeLocation({
161161
// @cypher is valid
162162
return;
163163
}
164+
if (directiveNode.name.value === "authentication") {
165+
// @authentication is valid
166+
return;
167+
}
164168
const isDirectiveCombinedWithCypher = traversedDef.directives?.some(
165169
(directive) => directive.name.value === "cypher"
166170
);
167-
if (directiveNode.name.value === "authentication" && isDirectiveCombinedWithCypher) {
168-
// @cypher @authentication combo is valid
169-
return;
170-
}
171171
// explicitly checked for "enhanced" error messages
172-
if (directiveNode.name.value === "authentication" && !isDirectiveCombinedWithCypher) {
173-
throw new DocumentValidationError(
174-
`Invalid directive usage: Directive @authentication is not supported on fields of the ${parentDef.name.value} type unless it is a @cypher field.`,
175-
[`@${directiveNode.name.value}`]
176-
);
177-
}
178172
if (directiveNode.name.value === "authorization" && isDirectiveCombinedWithCypher) {
179173
throw new DocumentValidationError(
180174
`Invalid directive usage: Directive @authorization is not supported on fields of the ${parentDef.name.value} type. Did you mean to use @authentication?`,

packages/graphql/src/schema/validation/validate-document.test.ts

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3436,35 +3436,6 @@ describe("validation 2.0", () => {
34363436
expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@relationship"]);
34373437
});
34383438

3439-
test("@authentication can't be used on the field of a root type", () => {
3440-
const doc = gql`
3441-
type Query {
3442-
someActors: [Actor!]! @authentication
3443-
}
3444-
3445-
type Actor {
3446-
name: String
3447-
}
3448-
`;
3449-
3450-
const executeValidate = () =>
3451-
validateDocument({
3452-
document: doc,
3453-
additionalDefinitions,
3454-
features: {},
3455-
});
3456-
3457-
const errors = getError(executeValidate);
3458-
3459-
expect(errors).toHaveLength(1);
3460-
expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError);
3461-
expect(errors[0]).toHaveProperty(
3462-
"message",
3463-
"Invalid directive usage: Directive @authentication is not supported on fields of the Query type unless it is a @cypher field."
3464-
);
3465-
expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@authentication"]);
3466-
});
3467-
34683439
test("@authorization can't be used on the field of a root type", () => {
34693440
const doc = gql`
34703441
type Query {
@@ -3597,6 +3568,27 @@ describe("validation 2.0", () => {
35973568
expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@populatedBy"]);
35983569
});
35993570

3571+
test("@authentication ok to be used on the field of a root type", () => {
3572+
const doc = gql`
3573+
type Query {
3574+
someActors: [Actor!]! @authentication
3575+
}
3576+
3577+
type Actor {
3578+
name: String
3579+
}
3580+
`;
3581+
3582+
const executeValidate = () =>
3583+
validateDocument({
3584+
document: doc,
3585+
additionalDefinitions,
3586+
features: {},
3587+
});
3588+
3589+
expect(executeValidate).not.toThrow();
3590+
});
3591+
36003592
test("@authentication with @cypher ok to be used on the field of a root type", () => {
36013593
const doc = gql`
36023594
type Query {

0 commit comments

Comments
 (0)