Skip to content

Commit 9b29251

Browse files
committed
Improve @auth example and corresponding tests.
1 parent 63cec8e commit 9b29251

File tree

2 files changed

+80
-66
lines changed

2 files changed

+80
-66
lines changed

docs/source/schema-directives.md

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,9 @@ GraphQL is great for internationalization, since a GraphQL server can access unl
270270

271271
### Enforcing access permissions
272272

273-
To implement the `@auth` example mentioned in the [**Declaring schema directives**](schema-directives.html#Declaring-schema-directives) section below:
273+
Imagine a hypothetical `@auth` directive that takes an argument `requires` of type `Role`, which defaults to `ADMIN`. This `@auth` directive can appear on an `OBJECT` like `User` to set default access permissions for all `User` fields, as well as appearing on individual fields, to enforce field-specific `@auth` restrictions:
274274

275-
```js
276-
const typeDefs = `
275+
```gql
277276
directive @auth(
278277
requires: Role = ADMIN,
279278
) on OBJECT | FIELD_DEFINITION
@@ -289,52 +288,57 @@ type User @auth(requires: USER) {
289288
name: String
290289
banned: Boolean @auth(requires: ADMIN)
291290
canPost: Boolean @auth(requires: REVIEWER)
292-
}`;
291+
}
292+
```
293293

294-
// Symbols can be a good way to store semi-hidden data on schema objects.
295-
const authRoleSymbol = Symbol.for("@auth role");
296-
const authWrapSymbol = Symbol.for("@auth wrapped");
294+
What makes this example tricky is that the `OBJECT` version of the directive needs to wrap all fields of the object, even though some of those fields may be individually wrapped by `@auth` directives at the `FIELD_DEFINITION` level, and we would prefer not to rewrap resolvers if we can help it:
297295

296+
```js
298297
class AuthDirective extends SchemaDirectiveVisitor {
299298
visitObject(type) {
300299
this.ensureFieldsWrapped(type);
301-
type[authRoleSymbol] = this.args.requires;
300+
type._requiredAuthRole = this.args.requires;
302301
}
303-
302+
// Visitor methods for nested types like fields and arguments
303+
// also receive a details object that provides information about
304+
// the parent and grandparent types.
304305
visitFieldDefinition(field, details) {
305306
this.ensureFieldsWrapped(details.objectType);
306-
field[authRoleSymbol] = this.args.requires;
307+
field._requiredAuthRole = this.args.requires;
307308
}
308309

309-
ensureFieldsWrapped(type) {
310-
// Mark the GraphQLObjectType object to avoid re-wrapping its fields:
311-
if (type[authWrapSymbol]) {
312-
return;
313-
}
310+
ensureFieldsWrapped(objectType) {
311+
// Mark the GraphQLObjectType object to avoid re-wrapping:
312+
if (objectType._authFieldsWrapped) return;
313+
objectType._authFieldsWrapped = true;
314+
315+
const fields = objectType.getFields();
314316

315-
const fields = type.getFields();
316317
Object.keys(fields).forEach(fieldName => {
317318
const field = fields[fieldName];
318319
const { resolve = defaultFieldResolver } = field;
319320
field.resolve = async function (...args) {
320-
// Get the required role from the field first, falling back to the
321-
// parent GraphQLObjectType if no role is required by the field:
322-
const requiredRole = field[authRoleSymbol] || type[authRoleSymbol];
321+
// Get the required Role from the field first, falling back
322+
// to the objectType if no Role is required by the field:
323+
const requiredRole =
324+
field._requiredAuthRole ||
325+
objectType._requiredAuthRole;
326+
323327
if (! requiredRole) {
324328
return resolve.apply(this, args);
325329
}
330+
326331
const context = args[2];
327332
const user = await getUser(context.headers.authToken);
328333
if (! user.hasRole(requiredRole)) {
329334
throw new Error("not authorized");
330335
}
336+
331337
return resolve.apply(this, args);
332338
};
333339
});
334-
335-
type[authWrapSymbol] = true;
336340
}
337-
};
341+
}
338342

339343
const schema = makeExecutableSchema({
340344
typeDefs,
@@ -346,6 +350,8 @@ const schema = makeExecutableSchema({
346350
});
347351
```
348352

353+
One drawback of this approach is that it does not guarantee fields will be wrapped if they are added to the schema after `AuthDirective` is applied, and the whole `getUser(context.headers.authToken)` is a made-up API that would need to be fleshed out. In other words, we’ve glossed over some of the details that would be required for a production-ready implementation of this directive, though we hope the basic structure shown here inspires you to find clever solutions to the remaining problems.
354+
349355
### Enforcing value restrictions
350356

351357
Suppose you want to enforce a maximum length for a string-valued field:

src/test/testDirectives.ts

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -678,8 +678,6 @@ describe('@directives', () => {
678678
});
679679

680680
it('can be used to implement the @auth example', async () => {
681-
const authReqSymbol = Symbol.for('@auth required role');
682-
const authWrapSymbol = Symbol.for('@auth wrapped');
683681
const roles = [
684682
'UNKNOWN',
685683
'USER',
@@ -697,6 +695,57 @@ describe('@directives', () => {
697695
};
698696
}
699697

698+
class AuthDirective extends SchemaDirectiveVisitor {
699+
public visitObject(type: GraphQLObjectType) {
700+
this.ensureFieldsWrapped(type);
701+
(type as any)._requiredAuthRole = this.args.requires;
702+
}
703+
// Visitor methods for nested types like fields and arguments
704+
// also receive a details object that provides information about
705+
// the parent and grandparent types.
706+
public visitFieldDefinition(
707+
field: GraphQLField<any, any>,
708+
details: { objectType: GraphQLObjectType },
709+
) {
710+
this.ensureFieldsWrapped(details.objectType);
711+
(field as any)._requiredAuthRole = this.args.requires;
712+
}
713+
714+
public ensureFieldsWrapped(objectType: GraphQLObjectType) {
715+
// Mark the GraphQLObjectType object to avoid re-wrapping:
716+
if ((objectType as any)._authFieldsWrapped) {
717+
return;
718+
}
719+
(objectType as any)._authFieldsWrapped = true;
720+
721+
const fields = objectType.getFields();
722+
723+
Object.keys(fields).forEach(fieldName => {
724+
const field = fields[fieldName];
725+
const { resolve = defaultFieldResolver } = field;
726+
field.resolve = async function (...args: any[]) {
727+
// Get the required Role from the field first, falling back
728+
// to the objectType if no Role is required by the field:
729+
const requiredRole =
730+
(field as any)._requiredAuthRole ||
731+
(objectType as any)._requiredAuthRole;
732+
733+
if (! requiredRole) {
734+
return resolve.apply(this, args);
735+
}
736+
737+
const context = args[2];
738+
const user = await getUser(context.headers.authToken);
739+
if (! user.hasRole(requiredRole)) {
740+
throw new Error('not authorized');
741+
}
742+
743+
return resolve.apply(this, args);
744+
};
745+
});
746+
}
747+
}
748+
700749
const schema = makeExecutableSchema({
701750
typeDefs: `
702751
directive @auth(
@@ -721,48 +770,7 @@ describe('@directives', () => {
721770
}`,
722771

723772
directives: {
724-
auth: class extends SchemaDirectiveVisitor {
725-
public visitObject(type: GraphQLObjectType) {
726-
this.ensureFieldsWrapped(type);
727-
type[authReqSymbol] = this.args.requires;
728-
}
729-
730-
public visitFieldDefinition(field: GraphQLField<any, any>, details: {
731-
objectType: GraphQLObjectType,
732-
}) {
733-
this.ensureFieldsWrapped(details.objectType);
734-
field[authReqSymbol] = this.args.requires;
735-
}
736-
737-
private ensureFieldsWrapped(type: GraphQLObjectType) {
738-
// Mark the GraphQLObjectType object to avoid re-wrapping its fields:
739-
if (type[authWrapSymbol]) {
740-
return;
741-
}
742-
743-
const fields = type.getFields();
744-
Object.keys(fields).forEach(fieldName => {
745-
const field = fields[fieldName];
746-
const { resolve = defaultFieldResolver } = field;
747-
field.resolve = async function (...args: any[]) {
748-
// Get the required role from the field first, falling back to the
749-
// parent GraphQLObjectType if no role is required by the field:
750-
const requiredRole = field[authReqSymbol] || type[authReqSymbol];
751-
if (! requiredRole) {
752-
return resolve.apply(this, args);
753-
}
754-
const context = args[2];
755-
const user = await getUser(context.headers.authToken);
756-
if (! user.hasRole(requiredRole)) {
757-
throw new Error('not authorized');
758-
}
759-
return resolve.apply(this, args);
760-
};
761-
});
762-
763-
type[authWrapSymbol] = true;
764-
}
765-
}
773+
auth: AuthDirective
766774
},
767775

768776
resolvers: {

0 commit comments

Comments
 (0)