Skip to content

Commit

Permalink
Merge pull request #13 from Phoenix-Commerce/feat/extend-app-through-…
Browse files Browse the repository at this point in the history
…plugins

feat: extend models and resolvers through plugins
  • Loading branch information
brent-hoover authored Jun 16, 2024
2 parents b9f8fd2 + ae69177 commit 57e271d
Show file tree
Hide file tree
Showing 20 changed files with 396 additions and 111 deletions.
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ jobs:
- name: Install dependencies
run: bun install

- name: Run linter
run: bun run lint

test:
runs-on: ubuntu-latest
needs: lint
Expand Down
29 changes: 29 additions & 0 deletions qodana.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
version: "1.0"

#Specify inspection profile for code analysis
profile:
name: qodana.starter

#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>

#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>

#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh

#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)

#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-js:latest
143 changes: 77 additions & 66 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,82 +6,93 @@ import env from './config/config';
import logger from './config/logger';
import { initEnforcer, getEnforcer } from './rbac';
import { authenticate } from './middleware/auth';
import { PluginLoader } from './plugins/plugin-loader';
import PluginLoader from './plugins/plugin-loader';
import { isIntrospectionQuery } from './utils/introspection-check';
import { shouldBypassAuth } from './utils/should-bypass-auth';
import { bootstrap } from './plugins/auth-plugin/bootstrap';
import sanitizeLog from './sanitize-log.ts';
import sanitizeLog from './sanitize-log';

const loggerCtx = 'index';
const loggerCtx = { context: 'index' };

async function startServer() {
await connectToDatabase();
await initEnforcer(); // Initialize Casbin
await bootstrap(); // Bootstrap the application with a superuser

const pluginLoader = new PluginLoader();
pluginLoader.loadPlugins();

const schema = await pluginLoader.createSchema();

const server = new ApolloServer({
schema,
introspection: true, // Ensure introspection is enabled
context: async ({ req }) => ({
user: req.user, // User object from middleware
enforcer: await getEnforcer(), // Casbin enforcer instance
}),
});

await server.start();

const app: Application = express();

app.use(express.json());

// Middleware to conditionally authenticate user and set user context
app.use('/graphql', (req: Request, res: Response, next: NextFunction) => {
const reqInfo = {
url: req.url,
method: req.method,
ip: req.ip,
headers: req.headers,
operation: {},
};

if (req.body && req.body.query) {
if (isIntrospectionQuery(req.body.query)) {
logger.info('Bypassing authentication for introspection query', loggerCtx);
return next(); // Bypass authentication for introspection queries
}
try {
await connectToDatabase();
await initEnforcer(); // Initialize Casbin
await bootstrap(); // Bootstrap the application with a superuser

if (shouldBypassAuth(req.body.query)) {
logger.info(`Bypassing authentication for due to excluded operation: ${req.body.query}`, loggerCtx);
return next(); // Bypass authentication for this request
}
const pluginLoader = new PluginLoader();
pluginLoader.loadPlugins();

try {
// If no operation bypasses authentication, apply authentication middleware
authenticate(req, res, next);
} catch (error) {
logger.error('Error parsing GraphQL query:', { error, query: req.body.query });
// Register models before initializing plugins
pluginLoader.registerModels();

// Initialize plugins (extend models and resolvers)
pluginLoader.initializePlugins();

const schema = await pluginLoader.createSchema();

const server = new ApolloServer({
schema,
introspection: true, // Ensure introspection is enabled
context: async ({ req }) => ({
user: req.user, // User object from middleware
enforcer: await getEnforcer(), // Casbin enforcer instance
pluginsContext: pluginLoader.context, // Add the global context here
}),
});

await server.start();

const app: Application = express();

app.use(express.json());

// Middleware to conditionally authenticate user and set user context
app.use('/graphql', (req: Request, res: Response, next: NextFunction) => {
const reqInfo = {
url: req.url,
method: req.method,
ip: req.ip,
headers: req.headers,
operation: {},
};

if (req.body && req.body.query) {
if (isIntrospectionQuery(req.body.query)) {
logger.info('Bypassing authentication for introspection query', loggerCtx);
return next(); // Bypass authentication for introspection queries
}

if (shouldBypassAuth(req.body.query)) {
logger.info(`Bypassing authentication due to excluded operation: ${req.body.query}`, loggerCtx);
return next(); // Bypass authentication for this request
}

try {
// If no operation bypasses authentication, apply authentication middleware
authenticate(req, res, next);
} catch (error) {
logger.error('Error parsing GraphQL query:', { error, query: req.body.query });
authenticate(req, res, next);
}
} else {
// If there is no query in the request body, continue with authentication
authenticate(req, res, next);
}
} else {
// If there is no query in the request body, continue with authentication
authenticate(req, res, next);
}
const sanitizedReqInfo = sanitizeLog(reqInfo);
const logLine = JSON.stringify(sanitizedReqInfo, null, 0);
logger.verbose(`reqInfo: ${logLine}`, loggerCtx);
});

server.applyMiddleware({ app });

const port = env.PORT;
app.listen(port, () => {
logger.info(`Server is running at http://localhost:${port}${server.graphqlPath}`, { context: 'server' });
});
const sanitizedReqInfo = sanitizeLog(reqInfo);
const logLine = JSON.stringify(sanitizedReqInfo, null, 0);
logger.verbose(`reqInfo: ${logLine}`, loggerCtx);
});

server.applyMiddleware({ app });

const port = env.PORT;
app.listen(port, () => {
logger.info(`Server is running at http://localhost:${port}${server.graphqlPath}`, { context: 'index' });
});
} catch (error) {
logger.error('Failed to start server:', error, loggerCtx);
}
}

startServer();
6 changes: 3 additions & 3 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
import jwt, { type JwtPayload } from 'jsonwebtoken';
import logger from '../config/logger.ts';

const loggerCtx = 'auth-middleware';
const loggerCtx = { context: 'auth-middleware' };

export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
Expand All @@ -13,7 +13,7 @@ export const authenticate = (req: Request, res: Response, next: NextFunction) =>

const token = authHeader.split(' ')[1];
if (!token) {
logger.error('Token missing');
logger.error('Token missing', loggerCtx);
return res.status(401).send('Token missing');
}

Expand All @@ -27,7 +27,7 @@ export const authenticate = (req: Request, res: Response, next: NextFunction) =>
req.user = decoded; // Attach the user to the request object
next();
} catch (err) {
console.log('Invalid token:', err);
logger.error('Invalid token:', err, loggerCtx);
return res.status(401).send('Invalid token');
}
};
11 changes: 7 additions & 4 deletions src/plugins/auth-plugin/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import bcrypt from 'bcrypt';
import { UserModel } from './entities/user';
import { UserModel } from './models/user';
import { getEnforcer } from '../../rbac';
import logger from '../../config/logger';

const loggerCtx = { context: 'auth-plugin-bootstrap' };
const email = 'superuser@example.com';

export const bootstrap = async () => {
const userCount = await UserModel.countDocuments({});
if (userCount === 0) {
const superuser = new UserModel({
email: 'superuser@example.com',
email: email,
password: await bcrypt.hash('superpassword', 10), // Use a secure password
name: 'Super User',
role: 'superadmin',
Expand All @@ -33,8 +36,8 @@ export const bootstrap = async () => {
}
}

logger.info('Superuser created with email: superuser@phoenix.com');
logger.info(`Superuser created with email: ${email}`);
} else {
logger.info('Users already exist. No superuser created.');
logger.info('Users already exist. No superuser created.', loggerCtx);
}
};
8 changes: 6 additions & 2 deletions src/plugins/auth-plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { AuthResolver } from './resolvers/auth-resolver';
import type { Plugin } from '../plugin-interface';
import FunctionRegistry from '../function-registry';
import { type GlobalContext } from '../global-context';

const authPlugin: Plugin = {
name: 'AuthPlugin',
name: 'auth-plugin',
type: 'authorization',
resolvers: [AuthResolver],
register: (container: any) => {
register: (container: any, context: GlobalContext) => {
// Register resolvers
context.resolvers['Auth'] = authPlugin.resolvers ?? [];

// Perform any additional registration if necessary
const functionRegistry = FunctionRegistry.getInstance();
functionRegistry.registerFunction('user', () => console.log('User function called'));
Expand Down
File renamed without changes.
8 changes: 2 additions & 6 deletions src/plugins/auth-plugin/resolvers/auth-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { Resolver, Mutation, Arg, Query, Ctx } from 'type-graphql';
import bcrypt from 'bcrypt';
import { UserModel } from '../entities/user';
import logger from '../../../config/logger';
import { User } from '../entities/user';
import { UserModel } from '../models/user';
import { User } from '../models/user';
import { UserService } from '../services/user-service.ts';
import { Service } from 'typedi';
import jwt from 'jsonwebtoken';
import { getEnforcer } from '../../../rbac';

const loggerCtx = 'auth-resolver';

@Service() // Register AuthResolver with Typedi
@Resolver()
export class AuthResolver {
Expand All @@ -19,7 +16,6 @@ export class AuthResolver {
this.userService = new UserService();
}


@Mutation(() => User)
async register(
@Arg('email') email: string,
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/auth-plugin/services/user-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { UserService } from './user-service.ts';
import { User, UserModel } from '../entities/user';
import { User, UserModel } from '../models/user';
import { instance, mock, when, anything } from 'ts-mockito';
import mongoose, { Document } from 'mongoose';
import { connectDB, closeDB } from '../../../../test/test-db-setup.ts';
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/auth-plugin/services/user-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Service } from 'typedi';
import { User, UserModel } from '../entities/user';
import { User, UserModel } from '../models/user';
import bcrypt from 'bcrypt';
import { getEnforcer } from '../../../rbac.ts';

Expand All @@ -11,7 +11,7 @@ export class UserService {

public async registerUser(name: string, email: string, password: string): Promise<User> {
const hashedPassword = await bcrypt.hash(password, 10);
const user = new UserModel({ name, email, password: hashedPassword, role : 'user'});
const user = new UserModel({ name, email, password: hashedPassword, role: 'user' });
// Add user-role mapping to Casbin
const enforcer = await getEnforcer();
await enforcer.addRoleForUser(user._id.toString(), 'user');
Expand Down
21 changes: 21 additions & 0 deletions src/plugins/cart-plugin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Container } from 'typedi';
import { getModelForClass } from '@typegoose/typegoose';
import { type GlobalContext } from '../global-context';
import { Cart } from './models/cart';
import { CartResolver } from './resolvers/cart-resolver';

export default {
name: 'cart-plugin',
type: 'cart',
resolvers: [CartResolver],
register(container: typeof Container, context: GlobalContext) {
const CartModel = getModelForClass(Cart);
context.models['Cart'] = { schema: CartModel.schema, model: CartModel };
container.set('CartModel', CartModel);
container.set(CartResolver, new CartResolver()); // Register CartResolver explicitly
context.extendResolvers('Cart', [CartResolver]);
const resolverMethods = Object.getOwnPropertyNames(CartResolver.prototype).filter(
(method) => method !== 'constructor',
);
},
};
34 changes: 34 additions & 0 deletions src/plugins/cart-plugin/models/cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Field, Float, Int, ObjectType } from 'type-graphql';
import { prop, getModelForClass } from '@typegoose/typegoose';

@ObjectType()
export class Item {
@Field()
@prop({ required: true })
public name!: string;

@Field()
@prop({ required: true })
public description!: string;

@Field()
@prop({ required: true })
public productId!: string;

@Field(() => Int)
@prop({ required: true })
public quantity!: number;

@Field(() => Float)
@prop({ required: true })
public price!: number;
}

@ObjectType()
export class Cart {
@Field(() => [Item])
@prop({ type: () => [Item], default: [] })
public items: Item[] | undefined;
}

export const CartModel = getModelForClass(Cart);
Loading

0 comments on commit 57e271d

Please sign in to comment.