Skip to content

Commit

Permalink
feat: add support for IoC in resolvers defined as classes (#52)
Browse files Browse the repository at this point in the history
Closes: #50
  • Loading branch information
targos authored Dec 21, 2023
1 parent 6ced7e8 commit 1b37226
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 15 deletions.
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,94 @@ Route.group(() => {
}).middleware('auth');
```

### Schema

The GraphQL schema should be defined in `.graphql` files (by default located in `app/Schemas`).
The schema folders are scanned recursively.

```graphql
type Query {
hello: String!
rectangle: Rectangle!
}

type Rectangle {
width: Int!
height: Int!
area: Int!
}
```

### Resolvers

Resolvers should be exported from `.ts` files (by default located in `app/Resolvers`).
Only the first level of resolver folders is scanned, so you can use sub-folders put additional code.

All resolvers are merged into a single object, so you can define them in multiple files.

There are two supported ways of defining resolvers:

#### Exporting classes

Multiple classes can be exported from a single file.
The name of the exported binding will be used as the name of the GraphQL type.

```ts
export class Query {
hello() {
return 'world';
}

rectangle() {
return { width: 10, height: 20 };
}
}

export class Rectangle {
area(rectangle) {
return rectangle.width * rectangle.height;
}
}
```

It is also possible to add the suffix `Resolvers` to the exported name to avoid potential conflicts:

```ts
interface Rectangle {
width: number;
height: number;
}

export class RectangleResolvers {
area(rectangle: Rectangle) {
return rectangle.width * rectangle.height;
}
}
```

#### Exporting a single object

When a single object is exported as default, it is assumed to be a map of resolvers.

```ts
interface Rectangle {
width: number;
height: number;
}

export default {
Query: {
hello: () => 'world',
rectangle() {
return { width: 10, height: 20 };
},
},
Rectangle: {
area: (rectangle: Rectangle) => rectangle.width * rectangle.height,
},
};
```

### Troubleshooting

#### Error: Query root type must be provided
Expand Down
1 change: 1 addition & 0 deletions src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export default class ApolloServer<
resolversPaths.map((resolverPath) =>
path.join(application.appRoot, resolverPath),
),
this.$app.container,
);

if (application.inDev) {
Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import path from 'node:path';

import { Ioc } from '@adonisjs/fold';
import { FakeLogger } from '@adonisjs/logger';
import { Kind } from 'graphql';

import { getTypeDefsAndResolvers, printWarnings } from '../schema';

const testIoC = new Ioc();

describe('getTypeDefsAndResolvers', () => {
const fixture = path.join(
__dirname,
Expand All @@ -13,14 +16,15 @@ describe('getTypeDefsAndResolvers', () => {
const result = getTypeDefsAndResolvers(
[path.join(fixture, 'schemas')],
[path.join(fixture, 'resolvers')],
testIoC,
);
it('should merge schemas', () => {
// Query, Mutation
expect(
result.typeDefs.definitions.filter(
(def) => def.kind === Kind.OBJECT_TYPE_DEFINITION,
),
).toHaveLength(2);
).toHaveLength(3);

// URL, Bad, OtherBad
expect(
Expand All @@ -34,6 +38,7 @@ describe('getTypeDefsAndResolvers', () => {
expect(Object.keys(result.resolvers)).toStrictEqual([
'Query',
'Mutation',
'D',
'URL',
]);
});
Expand Down
68 changes: 68 additions & 0 deletions src/loadResolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert';

import { loadFilesSync } from '@graphql-tools/load-files';
import { mergeResolvers } from '@graphql-tools/merge';

import { ContainerBindings, IocContract } from '@ioc:Adonis/Core/Application';

type UnknownConstructor = new (...args: unknown[]) => unknown;

const antiClashSuffix = 'Resolvers';

export function loadResolvers(
resolversPaths: string[],
container: IocContract<ContainerBindings>,
) {
const resolverModules = resolversPaths.flatMap((resolversPath) =>
loadFilesSync(resolversPath, { recursive: false }),
);
const resolversPartials = resolverModules.map((resolverModule) =>
makeResolversPartial(resolverModule, container),
);

return mergeResolvers(resolversPartials);
}

function makeResolversPartial(
resolverModule: Record<string, unknown>,
container: IocContract<ContainerBindings>,
) {
return Object.fromEntries(
Object.entries(resolverModule)
.filter(
([, value]) =>
(typeof value === 'object' && value !== null) ||
typeof value === 'function',
)
.map(([key, value]) => {
if (key.endsWith(antiClashSuffix) && key !== antiClashSuffix) {
key = key.slice(0, -antiClashSuffix.length);
}
if (typeof value === 'object' && value !== null) {
return [key, value];
} else {
assert(typeof value === 'function');
return [
key,
mapResolverClass(value as UnknownConstructor, container),
];
}
}),
);
}

function mapResolverClass(
value: UnknownConstructor,
container: IocContract<ContainerBindings>,
) {
const instance = container.make(value);
const prototype = Object.getPrototypeOf(instance);
return Object.fromEntries(
Object.entries(Object.getOwnPropertyDescriptors(prototype))
.filter(
([name, desc]) =>
name !== 'constructor' && typeof desc.value === 'function',
)
.map(([name, desc]) => [name, desc.value.bind(instance)]),
);
}
28 changes: 14 additions & 14 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { loadFilesSync } from '@graphql-tools/load-files';
import { mergeResolvers, mergeTypeDefs } from '@graphql-tools/merge';
import { mergeTypeDefs } from '@graphql-tools/merge';
import { Kind } from 'graphql';

import { ContainerBindings, IocContract } from '@ioc:Adonis/Core/Application';
import { LoggerContract } from '@ioc:Adonis/Core/Logger';

import { loadResolvers } from './loadResolvers';
import { scalarResolvers } from './scalarResolvers';

interface SchemaWarnings {
Expand All @@ -15,17 +17,13 @@ interface SchemaWarnings {
export function getTypeDefsAndResolvers(
schemasPaths: string[],
resolversPaths: string[],
container: IocContract<ContainerBindings>,
) {
const typeDefs = mergeTypeDefs(
schemasPaths.flatMap((schemasPath) => loadFilesSync(schemasPath)),
);
const resolvers = {
...mergeResolvers(
resolversPaths.flatMap((resolversPath) =>
loadFilesSync(resolversPath, { recursive: false }),
),
),
};

const resolvers = loadResolvers(resolversPaths, container);

const warnings: SchemaWarnings = {
missingQuery: [],
Expand All @@ -48,23 +46,25 @@ export function getTypeDefsAndResolvers(
} else if (definition.kind === Kind.OBJECT_TYPE_DEFINITION) {
const objectName = definition.name.value;

if (objectName === 'Query' && definition.fields) {
if (objectName === 'Query' && definition.fields && resolvers.Query) {
// Warn about missing Query resolvers.
const queryResolvers = resolvers.Query || {};
for (const queryField of definition.fields) {
const queryName = queryField.name.value;
// @ts-expect-error Using index signature for validation.
if (!queryResolvers[queryName]) {
if (!resolvers.Query[queryName]) {
warnings.missingQuery.push(queryName);
}
}
} else if (objectName === 'Mutation' && definition.fields) {
} else if (
objectName === 'Mutation' &&
definition.fields &&
resolvers.Mutation
) {
// Warn about missing Mutation resolvers.
const mutationResolvers = resolvers.Mutation || {};
for (const mutationField of definition.fields) {
const mutationName = mutationField.name.value;
// @ts-expect-error Using index signature for validation.
if (!mutationResolvers[mutationName]) {
if (!resolvers.Mutation[mutationName]) {
warnings.missingMutation.push(mutationName);
}
}
Expand Down
13 changes: 13 additions & 0 deletions test-utils/fixtures/schema/test1/resolvers/D.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

exports.Query = class Query {
queryD() {
return 'test';
}
};

exports.DResolvers = class D {
value() {
return 'test';
}
};
7 changes: 7 additions & 0 deletions test-utils/fixtures/schema/test1/schemas/Schema3.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type Query {
queryD: String
}

type D {
value: String!
}

0 comments on commit 1b37226

Please sign in to comment.