Skip to content

Commit

Permalink
feat(graphql): add support for auth checker and middleware
Browse files Browse the repository at this point in the history
Signed-off-by: Raymond Feng <[email protected]>
  • Loading branch information
raymondfeng committed Sep 2, 2020
1 parent 6497d81 commit bc8433c
Show file tree
Hide file tree
Showing 10 changed files with 296 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright IBM Corp. 2020. All Rights Reserved.
// Node module: @loopback/example-graphql
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {createBindingFromClass} from '@loopback/core';
import {GraphQLBindings, GraphQLServer} from '@loopback/graphql';
import {expect, supertest} from '@loopback/testlab';
import {RecipesDataSource} from '../../datasources';
import {RecipeResolver} from '../../graphql-resolvers/recipe-resolver';
import {RecipeRepository} from '../../repositories';
import {sampleRecipes} from '../../sample-recipes';
import {RecipeService} from '../../services/recipe.service';
import {exampleQuery} from './graphql-tests';

describe('GraphQL context', () => {
let server: GraphQLServer;
let repo: RecipeRepository;

before(givenServer);
after(stopServer);

it('invokes middleware', async () => {
await supertest(server.httpServer?.url)
.post('/graphql')
.set('content-type', 'application/json')
.accept('application/json')
.send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery})
.expect(200);
});

async function givenServer() {
server = new GraphQLServer({host: '127.0.0.1', port: 0});
server.resolver(RecipeResolver);

// Customize the GraphQL context with additional information for test verification
server.bind(GraphQLBindings.GRAPHQL_CONTEXT_RESOLVER).to(ctx => {
return {...ctx, meta: 'loopback'};
});

// Register a GraphQL middleware to verify context resolution
server.middleware((resolverData, next) => {
expect(resolverData.context).to.containEql({meta: 'loopback'});
return next();
});

server.bind('recipes').to([...sampleRecipes]);
const repoBinding = createBindingFromClass(RecipeRepository);
server.add(repoBinding);
server.add(createBindingFromClass(RecipesDataSource));
server.add(createBindingFromClass(RecipeService));
await server.start();
repo = await server.get<RecipeRepository>(repoBinding.key);
await repo.start();
}

async function stopServer() {
if (!server) return;
await server.stop();
repo.stop();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,94 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {createRestAppClient, givenHttpServerConfig} from '@loopback/testlab';
import {GraphqlDemoApplication} from '../../';
import {runTests} from './graphql-tests';
import {createBindingFromClass} from '@loopback/core';
import {GraphQLBindings, GraphQLServer} from '@loopback/graphql';
import {expect, supertest} from '@loopback/testlab';
import {RecipesDataSource} from '../../datasources';
import {RecipeResolver} from '../../graphql-resolvers/recipe-resolver';
import {RecipeRepository} from '../../repositories';
import {sampleRecipes} from '../../sample-recipes';
import {RecipeService} from '../../services/recipe.service';
import {exampleQuery} from './graphql-tests';

describe('GraphQL middleware', () => {
let app: GraphqlDemoApplication;
let server: GraphQLServer;
let repo: RecipeRepository;

before(giveAppWithGraphQLMiddleware);
after(stopApp);
beforeEach(givenServer);
afterEach(stopServer);

runTests(() => createRestAppClient(app));
it('invokes middleware', async () => {
const fieldNamesCapturedByMiddleware: string[] = [];
// Register a GraphQL middleware
server.middleware((resolverData, next) => {
// It's invoked for each field resolver
fieldNamesCapturedByMiddleware.push(resolverData.info.fieldName);
return next();
});

async function giveAppWithGraphQLMiddleware() {
app = new GraphqlDemoApplication({rest: givenHttpServerConfig()});
await app.boot();
await app.start();
return app;
await startServerAndRepo();
await supertest(server.httpServer?.url)
.post('/graphql')
.set('content-type', 'application/json')
.accept('application/json')
.send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery})
.expect(200);
expect(fieldNamesCapturedByMiddleware).to.eql([
// the query
'recipe',
// field resolvers
'title',
'description',
'ratings',
'creationDate',
'ratingsCount',
'averageRating',
'ingredients',
'numberInCollection',
]);
});

it('invokes authChecker', async () => {
const authChecks: string[] = [];
server
.bind(GraphQLBindings.GRAPHQL_AUTH_CHECKER)
.to((resolverData, roles) => {
authChecks.push(`${resolverData.info.fieldName} ${roles}`);
return true;
});
await startServerAndRepo();
await supertest(server.httpServer?.url)
.post('/graphql')
.set('content-type', 'application/json')
.accept('application/json')
.send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery})
.expect(200);
expect(authChecks).to.eql([
// the query
'recipe owner',
]);
});

async function givenServer() {
server = new GraphQLServer({host: '127.0.0.1', port: 0});
server.resolver(RecipeResolver);
server.bind('recipes').to([...sampleRecipes]);
const repoBinding = createBindingFromClass(RecipeRepository);
server.add(repoBinding);
server.add(createBindingFromClass(RecipesDataSource));
server.add(createBindingFromClass(RecipeService));
repo = await server.get<RecipeRepository>(repoBinding.key);
}

async function startServerAndRepo() {
await server.start();
await repo.start();
}

async function stopApp() {
await app?.stop();
async function stopServer() {
if (!server) return;
await server.stop();
repo.stop();
}
});
8 changes: 4 additions & 4 deletions examples/graphql/src/__tests__/acceptance/graphql-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function runTests(getClient: () => supertest.SuperTest<supertest.Test>) {
.post('/graphql')
.set('content-type', 'application/json')
.accept('application/json')
.send({operationName: 'GetRecipe1', variables: {}, query: example})
.send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery})
.expect(200, expectedData);
});

Expand All @@ -34,7 +34,7 @@ export function runTests(getClient: () => supertest.SuperTest<supertest.Test>) {
.post('/graphql')
.set('content-type', 'application/json')
.accept('application/json')
.send({operationName: 'AddRecipe', variables: {}, query: example})
.send({operationName: 'AddRecipe', variables: {}, query: exampleQuery})
.expect(200);
expect(res.body.data.addRecipe).to.containEql({
id: '4',
Expand Down Expand Up @@ -81,13 +81,13 @@ export function runTests(getClient: () => supertest.SuperTest<supertest.Test>) {
.post('/graphql')
.set('content-type', 'application/json')
.accept('application/json')
.send({operationName: 'GetRecipes', variables: {}, query: example})
.send({operationName: 'GetRecipes', variables: {}, query: exampleQuery})
.expect(200);
expect(res.body.data.recipes).to.eql(expectedRecipes);
});
}

const example = `query GetRecipe1 {
export const exampleQuery = `query GetRecipe1 {
recipe(recipeId: "1") {
title
description
Expand Down
27 changes: 26 additions & 1 deletion examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

import {Application, createBindingFromClass} from '@loopback/core';
import {GraphQLServer} from '@loopback/graphql';
import {supertest} from '@loopback/testlab';
import {
createRestAppClient,
givenHttpServerConfig,
supertest,
} from '@loopback/testlab';
import {GraphqlDemoApplication} from '../../';
import {RecipesDataSource} from '../../datasources';
import {RecipeResolver} from '../../graphql-resolvers/recipe-resolver';
import {RecipeRepository} from '../../repositories';
Expand Down Expand Up @@ -72,3 +77,23 @@ describe('GraphQL application', () => {
await app.stop();
}
});

describe('GraphQL as middleware', () => {
let app: GraphqlDemoApplication;

before(giveAppWithGraphQLMiddleware);
after(stopApp);

runTests(() => createRestAppClient(app));

async function giveAppWithGraphQLMiddleware() {
app = new GraphqlDemoApplication({rest: givenHttpServerConfig()});
await app.boot();
await app.start();
return app;
}

async function stopApp() {
await app?.stop();
}
});
2 changes: 2 additions & 0 deletions examples/graphql/src/graphql-resolvers/recipe-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import {inject, service} from '@loopback/core';
import {
arg,
authorized,
fieldResolver,
GraphQLBindings,
Int,
Expand Down Expand Up @@ -34,6 +35,7 @@ export class RecipeResolver implements ResolverInterface<Recipe> {
) {}

@query(returns => Recipe, {nullable: true})
@authorized('owner')
async recipe(@arg('recipeId') recipeId: string) {
return this.recipeRepo.getOne(recipeId);
}
Expand Down
71 changes: 70 additions & 1 deletion extensions/graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ The `GraphQLServer` allows you to propagate context from Express to resolvers.

### Register a GraphQL context resolver

The GraphQL context object can be built/enhanced by the context resolver. The
original value is `{req: Request, res: Response}` that represents the Express
request and response object.

```ts
export class GraphqlDemoApplication extends BootMixin(
RepositoryMixin(RestApplication),
Expand Down Expand Up @@ -303,11 +307,76 @@ export class RecipeResolver implements ResolverInterface<Recipe> {
@service(RecipeService) private readonly recipeService: RecipeService,
// It's possible to inject the resolver data
@inject(GraphQLBindings.RESOLVER_DATA) private resolverData: ResolverData,
) {}
) {
// `this.resolverData.context` is the GraphQL context
}
// ...
}
```

### Set up authorization checker

We can customize the `authChecker` for
[TypeGraphQL Authorization](https://typegraphql.com/docs/authorization.html).

```ts
export class GraphqlDemoApplication extends BootMixin(
RepositoryMixin(RestApplication),
) {
constructor(options: ApplicationConfig = {}) {
super(options);

// ...
// It's possible to register a graphql auth checker
this.bind(GraphQLBindings.GRAPHQL_AUTH_CHECKER).to(
(resolverData, roles) => {
// Use resolverData and roles for authorization
return true;
},
);
}
// ...
}
```

The resolver classes and graphql types can be decorated with `@authorized` to
enforce authorization.

```ts
@resolver(of => Recipe)
export class RecipeResolver implements ResolverInterface<Recipe> {
constructor() {} // ...

@query(returns => Recipe, {nullable: true})
@authorized('owner') // Authorized against `owner` role
async recipe(@arg('recipeId') recipeId: string) {
return this.recipeRepo.getOne(recipeId);
}
}
```

## Register GraphQL middleware

We can register one or more
[TypeGraphQL Middleware](https://typegraphql.com/docs/middlewares.html) as
follows:

```ts
export class GraphqlDemoApplication extends BootMixin(
RepositoryMixin(RestApplication),
) {
constructor(options: ApplicationConfig = {}) {
super(options);

// Register a GraphQL middleware
this.middleware((resolverData, next) => {
// It's invoked for each field resolver, query and mutation operations
return next();
});
}
}
```

## Try it out

Check out
Expand Down
2 changes: 2 additions & 0 deletions extensions/graphql/src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Arg,
Args,
ArgsType,
Authorized,
Field,
FieldResolver,
InputType,
Expand Down Expand Up @@ -36,3 +37,4 @@ export const root = Root;
export const field = Field;
export const inputType = InputType;
export const objectType = ObjectType;
export const authorized = Authorized;
11 changes: 10 additions & 1 deletion extensions/graphql/src/graphql.container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import {
filterByKey,
filterByServiceInterface,
} from '@loopback/core';
import {ExpressContext} from 'apollo-server-express/dist/ApolloServer';
import debugFactory from 'debug';
import {ContainerType, ResolverData} from 'type-graphql';
import {GraphQLBindings, GraphQLTags} from './keys';

const debug = debugFactory('loopback:graphql:container');
const MIDDLEWARE_CONTEXT = Symbol.for('loopback.middleware.context');

/**
* Context for graphql resolver resolution
Expand Down Expand Up @@ -43,8 +45,15 @@ export class LoopBackContainer implements ContainerType {
resolverData: ResolverData<unknown>,
) {
debug('Resolving a resolver %s', resolverClass.name, resolverData);

// Check if the resolverData has the LoopBack RequestContext
const graphQLCtx = resolverData.context as ExpressContext;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reqCtx = (graphQLCtx.req as any)[MIDDLEWARE_CONTEXT];
const parent = reqCtx ?? this.ctx;

const resolutionCtx = new GraphQLResolutionContext(
this.ctx,
parent,
resolverClass,
resolverData,
);
Expand Down
Loading

0 comments on commit bc8433c

Please sign in to comment.