Skip to content

Commit

Permalink
Merge pull request #3 from BrewInteractive/feature/create-hasura-service
Browse files Browse the repository at this point in the history
Feature/create hasura service
  • Loading branch information
mfozmen authored May 23, 2024
2 parents 3b77c9e + aca437d commit 2a07191
Show file tree
Hide file tree
Showing 14 changed files with 1,047 additions and 915 deletions.
25 changes: 13 additions & 12 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
{
"sonarlint.connectedMode.project": {
"connectionId": "brewinteractive",
"projectKey": "BrewInteractive_nestjs-hasura-module"
},
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.formatOnType": false, // required
"editor.formatOnPaste": true, // optional
"editor.formatOnSave": true, // optional
"editor.formatOnSaveMode": "file", // required to format on save
"files.autoSave": "onFocusChange", // optional but recommended
"vs-code-prettier-eslint.prettierLast": false // set as "true" to run 'prettier' last not first
}
"sonarlint.connectedMode.project": {
"connectionId": "brewinteractive",
"projectKey": "BrewInteractive_nestjs-hasura-module"
},
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.formatOnType": false, // required
"editor.formatOnPaste": true, // optional
"editor.formatOnSave": true, // optional
"editor.formatOnSaveMode": "file", // required to format on save
"files.autoSave": "onFocusChange", // optional but recommended
"vs-code-prettier-eslint.prettierLast": false,
"cSpell.words": ["Hasura"] // set as "true" to run 'prettier' last not first
}
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"graphql": "^16.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"graphql": "^16.8.1",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
Expand Down Expand Up @@ -82,7 +84,7 @@
},
"homepage": "https://github.com/BrewInteractive/nestjs-hasura#readme-module",
"dependencies": {
"graphql-request": "^7.0.1"
"graphql-request": "^6.1.0"
},
"packageManager": "[email protected]+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
}
8 changes: 4 additions & 4 deletions src/hasura.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Test } from '@nestjs/testing';
describe('HasuraModule', () => {
const hasuraConfig = MockFactory(HasuraConfigFixture).one();

it('Should be defined (With register method)', async () => {
it('should be defined (with register method)', async () => {
const module = await Test.createTestingModule({
imports: [HasuraModule.register(hasuraConfig)],
}).compile();
Expand All @@ -16,7 +16,7 @@ describe('HasuraModule', () => {
expect(service).toBeDefined();
});

it('Should be defined (With registerAsync method)', async () => {
it('should be defined (with registerAsync method)', async () => {
const module = await Test.createTestingModule({
imports: [
HasuraModule.registerAsync({
Expand All @@ -32,7 +32,7 @@ describe('HasuraModule', () => {
expect(service).toBeDefined();
});

it('Should be defined (With forRoot method)', async () => {
it('should be defined (With forRoot method)', async () => {
const module = await Test.createTestingModule({
imports: [HasuraModule.forRoot(hasuraConfig)],
}).compile();
Expand All @@ -41,7 +41,7 @@ describe('HasuraModule', () => {
expect(service).toBeDefined();
});

it('Should be defined (With forRootAsync method)', async () => {
it('should be defined (with forRootAsync method)', async () => {
const module = await Test.createTestingModule({
imports: [
HasuraModule.forRootAsync({
Expand Down
149 changes: 144 additions & 5 deletions src/hasura.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,157 @@
import { AuthorizationOptions, HasuraConfig, RequestFlags } from './models';
import { Faker, MockFactory } from 'mockingbird';
import { Test, TestingModule } from '@nestjs/testing';

import { HasuraConfigFixture } from '../test/fixtures';
import { HasuraService } from './hasura.service';
import { gql } from 'graphql-request';

const graphqlClientSpy = jest.fn();

jest.mock('graphql-request', () => {
return {
GraphQLClient: jest.fn().mockImplementation(() => ({
request: graphqlClientSpy,
})),
gql: jest.requireActual('graphql-request').gql,
};
});

describe('HasuraService', () => {
let service: HasuraService;
let hasuraService: HasuraService;
const hasuraConfig = MockFactory(HasuraConfigFixture).one();

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HasuraService],
providers: [
HasuraService,
{
provide: HasuraConfig,
useValue: hasuraConfig,
},
],
}).compile();

service = module.get<HasuraService>(HasuraService);
hasuraService = module.get<HasuraService>(HasuraService);
});

it('should be defined', () => {
expect(service).toBeDefined();
it('should be defined', async () => {
expect(hasuraService).toBeDefined();
});

it('should run the query without the variables.', async () => {
const query = gql`
query testQuery {
books {
id
}
}
`;

const expectedResult = [{ id: Faker.datatype.number() }];
graphqlClientSpy.mockResolvedValue(expectedResult);

const actualResult = await hasuraService.requestAsync({ query });

expect(actualResult).toBe(expectedResult);
expect(graphqlClientSpy).toHaveBeenCalledWith(query, undefined, {});
});

it('should run the query with the variables.', async () => {
const variables = {
id: Faker.datatype.number(),
};
const query = gql`
query testQuery($id: Int!) {
books_by_id(id: $id) {
id
}
}
`;

const expectedResult = { id: Faker.datatype.number() };
graphqlClientSpy.mockResolvedValue(expectedResult);

const actualResult = await hasuraService.requestAsync({
query,
variables,
});

expect(actualResult).toBe(expectedResult);
expect(graphqlClientSpy).toHaveBeenCalledWith(query, variables, {});
});

it('should run the query with the runQueryFlag.', async () => {
const query = gql`
query testQuery($id: Int!) {
books_by_id(id: $id) {
id
}
}
`;
const requestFlags: RequestFlags = RequestFlags.UseBackendOnlyPermissions;

const expectedResult = { id: Faker.datatype.number() };
graphqlClientSpy.mockResolvedValue(expectedResult);

const actualResult = await hasuraService.requestAsync({
query,
requestFlags,
});

expect(actualResult).toBe(expectedResult);
expect(graphqlClientSpy).toHaveBeenCalledWith(query, undefined, {
'x-hasura-use-backend-only-permissions': true,
'x-hasura-admin-secret': hasuraConfig.adminSecret,
});
});

it('should run the query with the runQueryOptions.', async () => {
const query = gql`
query testQuery($id: Int!) {
books_by_id(id: $id) {
id
}
}
`;

const authorizationOptions: AuthorizationOptions = {
role: Faker.datatype.string(),
authorizationToken: Faker.datatype.string(),
};

const expectedResult = { id: Faker.datatype.number() };
graphqlClientSpy.mockResolvedValue(expectedResult);

const actualResult = await hasuraService.requestAsync({
query,
authorizationOptions,
});

expect(actualResult).toBe(expectedResult);
expect(graphqlClientSpy).toHaveBeenCalledWith(query, undefined, {
'x-hasura-role': authorizationOptions.role,
authorization: authorizationOptions.authorizationToken,
});
});

it('should throw error if UseAdminSecret flag is set without setting admin secret in config.', async () => {
Reflect.set(hasuraService, 'adminSecret', undefined);
const query = gql`
query testQuery {
books {
id
}
}
`;

const requestFlags: RequestFlags = RequestFlags.UseAdminSecret;

expect(async () => {
await hasuraService.requestAsync({
query,
requestFlags,
});
}).rejects.toThrow(Error);
});
});
62 changes: 61 additions & 1 deletion src/hasura.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,64 @@
import {
AuthorizationOptions,
HasuraConfig,
HasuraHeaders,
HasuraRequest,
RequestFlags,
} from './models';
import { GraphQLClient, Variables } from 'graphql-request';

import { Injectable } from '@nestjs/common';

@Injectable()
export class HasuraService {}
export class HasuraService {
private readonly graphQLClient: GraphQLClient;
private readonly adminSecret?: string;

constructor(private readonly hasuraConfig: HasuraConfig) {
this.graphQLClient = new GraphQLClient(this.hasuraConfig.graphqlEndpoint);
this.adminSecret = this.hasuraConfig?.adminSecret;
}

requestAsync<T, V extends Variables = Variables>(
hasuraRequest: HasuraRequest<V>,
): Promise<T> {
const headers = {
...(hasuraRequest?.headers || {}),
...this.createHeadersByRunQueryFlags(hasuraRequest?.requestFlags),
...this.createHeadersByAuthorizationOptions(
hasuraRequest?.authorizationOptions || {},
),
};

return this.graphQLClient.request<T>(
hasuraRequest.query,
hasuraRequest.variables,
headers,
);
}

private getAdminSecret(): string {
if (this.adminSecret) return this.adminSecret;

throw new Error('Missing admin secret.');
}

private createHeadersByRunQueryFlags(flags: RequestFlags) {
const headers = {};
if ((RequestFlags.UseAdminSecret | flags) == flags)
headers['x-hasura-admin-secret'] = this.getAdminSecret();

if ((RequestFlags.UseAdminSecret | flags) == flags)
headers['x-hasura-use-backend-only-permissions'] = true;

return headers;
}

private createHeadersByAuthorizationOptions(options: AuthorizationOptions) {
return Object.entries(options).reduce((acc, [key, value]) => {
const headerKey = HasuraHeaders[key];
if (headerKey) acc[headerKey] = value;
return acc;
}, {});
}
}
4 changes: 2 additions & 2 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { HasuraModule, HasuraService } from './index';

describe('HasuraTest', () => {
it('Should export HasuraModule', () => {
it('should export HasuraModule', () => {
expect(HasuraModule).toBeDefined();
});

it('Should export HasuraService', () => {
it('should export HasuraService', () => {
expect(HasuraService).toBeDefined();
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './hasura.module';
export * from './hasura.service';
export * from './models';
4 changes: 4 additions & 0 deletions src/models/authorization-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class AuthorizationOptions {
authorizationToken?: string;
role?: string;
}
2 changes: 1 addition & 1 deletion src/models/hasura-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export class HasuraConfig {
graphqlEndpoint: string;
adminSecret: string;
adminSecret?: string;
}
4 changes: 4 additions & 0 deletions src/models/hasura-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const HasuraHeaders: Record<string, string> = {
authorizationToken: 'authorization',
role: 'x-hasura-role',
};
10 changes: 10 additions & 0 deletions src/models/hasura-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { AuthorizationOptions } from './authorization-options';
import { RequestFlags } from './request-flags';

export class HasuraRequest<V = unknown> {
query: string;
variables?: V;
headers?: Record<string, string>;
requestFlags?: RequestFlags;
authorizationOptions?: AuthorizationOptions;
}
4 changes: 4 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export * from './hasura-config';
export * from './hasura-async-config';
export * from './hasura-request';
export * from './request-flags';
export * from './authorization-options';
export * from './hasura-headers';
4 changes: 4 additions & 0 deletions src/models/request-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum RequestFlags {
UseAdminSecret = 1, // 0001
UseBackendOnlyPermissions = 3, // 0011
}
Loading

0 comments on commit 2a07191

Please sign in to comment.