Skip to content

Commit

Permalink
feat: controller method decorators (#100)
Browse files Browse the repository at this point in the history
  • Loading branch information
ssube committed Jan 1, 2019
1 parent 43e1962 commit e0121f7
Show file tree
Hide file tree
Showing 20 changed files with 343 additions and 371 deletions.
2 changes: 2 additions & 0 deletions config/tslint.cc.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@
],
"new-parens": true,
"no-angle-bracket-type-assertion": true,
"no-any": true,
"no-arg": true,
"no-bitwise": false,
"no-boolean-literal-compare": true,
"no-conditional-assignment": true,
"no-console": true,
"no-construct": true,
Expand Down
137 changes: 41 additions & 96 deletions src/controller/AccountController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isNil } from 'lodash';
import { Inject } from 'noicejs';
import { Connection, In, Repository } from 'typeorm';

import { CheckRBAC, HandleNoun, HandleVerb } from 'src/controller';
import { BaseController, ErrorReplyType } from 'src/controller/BaseController';
import { createCompletion } from 'src/controller/CompletionController';
import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller';
Expand Down Expand Up @@ -55,83 +56,36 @@ export class AccountController extends BaseController<AccountControllerData> imp
this.userRepository = this.storage.getCustomRepository(UserRepository);
}

public async handle(cmd: Command): Promise<void> {
switch (cmd.noun) {
case NOUN_ACCOUNT:
return this.handleAccount(cmd);
case NOUN_GRANT:
return this.handleGrant(cmd);
case NOUN_SESSION:
return this.handleSession(cmd);
default:
return this.reply(cmd.context, `unsupported noun: ${cmd.noun}`);
}
}

public async handleAccount(cmd: Command): Promise<void> {
switch (cmd.verb) {
case CommandVerb.Create:
return this.createAccount(cmd);
case CommandVerb.Delete:
return this.deleteAccount(cmd);
default:
return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`);
}
}

public async handleGrant(cmd: Command): Promise<void> {
switch (cmd.verb) {
case CommandVerb.Get:
return this.getGrant(cmd);
case CommandVerb.List:
return this.listGrants(cmd);
default:
return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`);
}
}

public async handleSession(cmd: Command): Promise<void> {
switch (cmd.verb) {
case CommandVerb.Create:
return this.createSession(cmd);
case CommandVerb.Get:
return this.getSession(cmd);
default:
return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`);
}
}

@HandleNoun(NOUN_GRANT)
@HandleVerb(CommandVerb.Get)
@CheckRBAC()
public async getGrant(cmd: Command): Promise<void> {
if (!this.checkGrants(cmd.context, 'grant:get')) {
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
}

const grants = cmd.get('grants');
const results = grants.map((p) => {
return `\`${p}: ${cmd.context.checkGrants([p])}\``;
}).join('\n');
return this.reply(cmd.context, results);
}

@HandleNoun(NOUN_GRANT)
@HandleVerb(CommandVerb.List)
@CheckRBAC()
public async listGrants(cmd: Command): Promise<void> {
if (!this.checkGrants(cmd.context, 'grant:list')) {
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
}

const grants = cmd.get('grants');
const results = grants.map((p) => {
return `\`${p}: ${cmd.context.listGrants([p])}\``;
}).join('\n');
return this.reply(cmd.context, results);
}

@HandleNoun(NOUN_ACCOUNT)
@HandleVerb(CommandVerb.Create)
public async createAccount(cmd: Command): Promise<void> {
if (!this.checkGrants(cmd.context, 'account:create') && !this.data.join.allow) {
if (!this.data.join.allow && !this.checkGrants(cmd.context, 'account:create')) {
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
}

const name = cmd.getHeadOrDefault('name', cmd.context.name);

if (await this.userRepository.count({
name,
})) {
Expand All @@ -153,63 +107,54 @@ export class AccountController extends BaseController<AccountControllerData> imp
return this.reply(cmd.context, `user ${name} joined, sign in token: ${jwt}`);
}

@HandleNoun(NOUN_ACCOUNT)
@HandleVerb(CommandVerb.Delete)
@CheckRBAC()
public async deleteAccount(cmd: Command): Promise<void> {
if (isNil(cmd.context.user)) {
return this.errorReply(cmd.context, ErrorReplyType.SessionMissing);
}

if (!this.checkGrants(cmd.context, 'account:delete')) {
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
}
const user = this.getUserOrFail(cmd.context);

if (cmd.getHeadOrDefault('confirm', 'no') !== 'yes') {
const completion = createCompletion(cmd, 'confirm', `please confirm deleting all tokens for ${cmd.context.user.name}`);
const completion = createCompletion(cmd, 'confirm', `please confirm deleting all tokens for ${user.name}`);
await this.bot.executeCommand(completion);
return;
}

await this.tokenRepository.delete({
subject: cmd.context.user.id,
subject: user.id,
});

const jwt = await this.createToken(cmd.context.user);
return this.reply(cmd.context, `revoked tokens for ${cmd.context.user.name}, new sign in token: ${jwt}`);
const jwt = await this.createToken(user);
return this.reply(cmd.context, `revoked tokens for ${user.name}, new sign in token: ${jwt}`);
}

@HandleNoun(NOUN_SESSION)
@HandleVerb(CommandVerb.Create)
public async createSession(cmd: Command): Promise<void> {
if (isNil(cmd.context.source)) {
return this.reply(cmd.context, 'no source listener with which to create a session');
}
const jwt = cmd.getHead('token');
const token = Token.verify(jwt, this.data.token.secret, {
audience: this.data.token.audience,
issuer: this.data.token.issuer,
});
this.logger.debug({ token }, 'creating session from token');

try {
const jwt = cmd.getHead('token');
const token = Token.verify(jwt, this.data.token.secret, {
audience: this.data.token.audience,
issuer: this.data.token.issuer,
});
this.logger.debug({ token }, 'creating session from token');

const user = await this.userRepository.findOneOrFail({
id: token.sub,
});
await this.userRepository.loadRoles(user);
this.logger.debug({ user }, 'logging in user');

const session = await cmd.context.source.createSession(cmd.context.uid, user);
this.logger.debug({ session, user }, 'created session');
return this.reply(cmd.context, 'created session');
} catch (err) {
this.logger.error(err, 'error creating session');
return this.reply(cmd.context, err.message);
}
const user = await this.userRepository.findOneOrFail({
id: token.sub,
});
await this.userRepository.loadRoles(user);
this.logger.debug({ user }, 'logging in user');

const source = this.getSourceOrFail(cmd.context);
const session = await source.createSession(cmd.context.uid, user);
this.logger.debug({ session, user }, 'created session');
return this.reply(cmd.context, 'created session');
}

@HandleNoun(NOUN_SESSION)
@HandleVerb(CommandVerb.Get)
@CheckRBAC()
public async getSession(cmd: Command): Promise<void> {
if (isNil(cmd.context.source)) {
return this.reply(cmd.context, 'no source listener with which to create a session');
}

const session = cmd.context.source.getSession(cmd.context.uid);
const source = this.getSourceOrFail(cmd.context);
const session = source.getSession(cmd.context.uid);
if (isNil(session)) {
return this.reply(cmd.context, 'cannot get sessions unless logged in');
}
Expand Down
78 changes: 75 additions & 3 deletions src/controller/BaseController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isString } from 'lodash';
import { Inject } from 'noicejs';
import { isNil, isString } from 'lodash';
import { Inject, MissingValueError } from 'noicejs';

import { BotService } from 'src/BotService';
import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller';
Expand All @@ -9,9 +9,13 @@ import { Message } from 'src/entity/Message';
import { ServiceModule } from 'src/module/ServiceModule';
import { ServiceDefinition } from 'src/Service';
import { Transform, TransformData } from 'src/transform/Transform';
import { getMethods } from 'src/utils';
import { TYPE_JSON, TYPE_TEXT } from 'src/utils/Mime';
import { TemplateScope } from 'src/utils/Template';

import { getHandlerOptions, HandlerOptions } from '.';

export type HandlerMethod = (this: BaseController<ControllerData>, cmd: Command) => Promise<void>;
export type BaseControllerOptions<TData extends ControllerData> = ControllerOptions<TData>;

export enum ErrorReplyType {
Expand All @@ -22,6 +26,7 @@ export enum ErrorReplyType {
InvalidVerb = 'invalid-verb',
SessionExists = 'session-exists',
SessionMissing = 'session-missing',
Unknown = 'unknown',
}

@Inject('services')
Expand Down Expand Up @@ -69,7 +74,58 @@ export abstract class BaseController<TData extends ControllerData> extends BotSe
return true;
}

public abstract handle(cmd: Command): Promise<void>;
public async handle(cmd: Command): Promise<void> {
this.logger.debug({ cmd }, 'finding handler method for command');

for (const method of getMethods(this)) {
const options = getHandlerOptions(method);
if (isNil(options)) {
continue;
}

this.logger.debug({ cmd, options }, 'checking potential handler method');
if (!this.checkCommand(cmd, options)) {
continue;
}

this.logger.debug({ method: method.name, options }, 'found matching handler method');
return this.invokeHandler(cmd, options, method as HandlerMethod);
}

this.logger.warn({ cmd }, 'no handler method for command');
}

protected checkCommand(cmd: Command, options: HandlerOptions): boolean {
return cmd.noun === options.noun && cmd.verb === options.verb;
}

protected async invokeHandler(cmd: Command, options: HandlerOptions, handler: HandlerMethod): Promise<void> {
if (options.rbac) {
if (options.rbac.user && !cmd.context.user) {
return this.errorReply(cmd.context, ErrorReplyType.SessionMissing);
}

const grants = [];
if (Array.isArray(options.rbac.grants)) {
grants.push(...options.rbac.grants);
}

if (options.rbac.defaultGrant) {
grants.push(`${options.noun}:${options.verb}`);
}

if (!cmd.context.checkGrants(grants)) {
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
}
}

try {
return handler.call(this, cmd);
} catch (err) {
this.logger.error(err, 'error during handler method');
return this.errorReply(cmd.context, ErrorReplyType.Unknown, err.message);
}
}

protected async transform(cmd: Command, type: string, body: TemplateScope): Promise<TemplateScope> {
if (this.transforms.length === 0) {
Expand Down Expand Up @@ -116,4 +172,20 @@ export abstract class BaseController<TData extends ControllerData> extends BotSe
protected async reply(ctx: Context, body: string): Promise<void> {
await this.bot.sendMessage(Message.reply(ctx, TYPE_TEXT, body));
}

protected getSourceOrFail(ctx: Context) {
const source = ctx.source;
if (isNil(source)) {
throw new MissingValueError('context source must not be nil');
}
return source;
}

protected getUserOrFail(ctx: Context) {
const user = ctx.user;
if (isNil(user)) {
throw new MissingValueError('context user must not be nil');
}
return user;
}
}
Loading

0 comments on commit e0121f7

Please sign in to comment.