Skip to content

Commit

Permalink
feat(ability): adds possibility to specify custom error messages (#245)
Browse files Browse the repository at this point in the history
Fixes #241
  • Loading branch information
stalniy authored Dec 22, 2019
1 parent cbd252c commit 4b48562
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 67 deletions.
3 changes: 3 additions & 0 deletions .codeclimate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ plugins:
enabled: false
import/no-extraneous-dependencies:
enabled: false
no-console:
enabled: false

exclude_patterns:
- docs/**/*
- "**/spec/**/*"
Expand Down
7 changes: 7 additions & 0 deletions packages/casl-ability/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,16 @@ export class AbilityBuilder extends AbilityBuilderParts {
static extract(): AbilityBuilderParts
}

type GenerateErrorMessage = (error: ForbiddenError) => string

export class ForbiddenError extends Error {
subject: any
subjectName: string
action: string
field: string

static setDefaultMessage(fnOrMessage: string | GenerateErrorMessage | null): void
static from(ability: Ability): ForbiddenError

throwUnlessCan(action: string, subject: any, field?: string): void
}
35 changes: 1 addition & 34 deletions packages/casl-ability/spec/ability.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AbilityBuilder, ForbiddenError, Ability } from '../src'
import { AbilityBuilder, Ability } from '../src'
import { Post } from './spec_helper'

describe('Ability', () => {
Expand Down Expand Up @@ -143,39 +143,6 @@ describe('Ability', () => {
expect(ability).not.to.allow('publish', 'Post')
})

describe('`throwUnlessCan` method', () => {
it('raises forbidden exception on disallowed action', () => {
expect(() => ability.throwUnlessCan('archive', 'Post')).to.throw(ForbiddenError)
})

it('does not raise forbidden exception on allowed action', () => {
expect(() => ability.throwUnlessCan('read', 'Post')).not.to.throw(Error)
})

it('raises error with context information', () => {
let error = new Error('No error raised')

try {
ability.throwUnlessCan('archive', 'Post')
} catch (abilityError) {
error = abilityError
}

expect(error).to.have.property('action').that.equal('archive')
expect(error).to.have.property('subject').that.equal('Post')
expect(error).to.have.property('subjectName').that.equal('Post')
})

it('raises error with message provided in `reason` field of forbidden rule', () => {
const NO_CARD_MESSAGE = 'No credit card provided'
const userAbility = AbilityBuilder.define((can, cannot) => {
cannot('update', 'Post').because(NO_CARD_MESSAGE)
})

expect(() => userAbility.throwUnlessCan('update', 'Post')).to.throw(NO_CARD_MESSAGE)
})
})

describe('`update` method', () => {
let updateHandler

Expand Down
65 changes: 65 additions & 0 deletions packages/casl-ability/spec/error.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Ability, ForbiddenError } from '../src'

describe('`ForbiddenError` class', () => {
let ability
let error

beforeEach(() => {
ability = new Ability([
{ action: 'read', subject: 'Post' }
])
error = ForbiddenError.from(ability)
})

describe('`throwUnlessCan` method', () => {
it('raises forbidden exception on disallowed action', () => {
expect(() => error.throwUnlessCan('archive', 'Post')).to.throw(ForbiddenError)
})

it('does not raise forbidden exception on allowed action', () => {
expect(() => ability.throwUnlessCan('read', 'Post')).not.to.throw(ForbiddenError)
})

it('raises error with context information', () => {
let thrownError = new Error('No error raised')

try {
error.throwUnlessCan('archive', 'Post')
} catch (abilityError) {
thrownError = abilityError
}

expect(thrownError).to.have.property('action').that.equal('archive')
expect(thrownError).to.have.property('subject').that.equal('Post')
expect(thrownError).to.have.property('subjectName').that.equal('Post')
})

it('raises error with message provided in `reason` field of forbidden rule', () => {
const NO_CARD_MESSAGE = 'No credit card provided'
ability.update([{
action: 'update',
subject: 'Post',
inverted: true,
reason: NO_CARD_MESSAGE
}])

expect(() => error.throwUnlessCan('update', 'Post')).to.throw(NO_CARD_MESSAGE)
})

it('can raise error with custom message', () => {
const message = 'My custom error message'
expect(() => error.setMessage(message).throwUnlessCan('update', 'Post')).to.throw(message)
})
})

describe('`setDefaultMessage` method', () => {
afterEach(() => {
ForbiddenError.setDefaultMessage(null)
})

it('sets default message from function', () => {
ForbiddenError.setDefaultMessage(err => `${err.action}-${err.subjectName}`)
expect(() => error.throwUnlessCan('update', 'Post')).to.throw('update-Post')
})
})
})
26 changes: 9 additions & 17 deletions packages/casl-ability/src/ability.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ export class Ability {
return this;
}

constructor(rules, { RuleType = Rule, subjectName = getSubjectName } = {}) {
constructor(rules, options = {}) {
Object.defineProperty(this, 'subjectName', { value: options.subjectName || getSubjectName });
this[PRIVATE_FIELD] = {
RuleType,
subjectName,
RuleType: options.RuleType || Rule,
originalRules: rules || [],
hasPerFieldRules: false,
indexedRules: Object.create(null),
Expand Down Expand Up @@ -146,7 +146,7 @@ export class Ability {
}

possibleRulesFor(action, subject) {
const subjectName = this[PRIVATE_FIELD].subjectName(subject);
const subjectName = this.subjectName(subject);
const { mergedRules } = this[PRIVATE_FIELD];
const key = `${subjectName}_${action}`;

Expand Down Expand Up @@ -189,19 +189,11 @@ export class Ability {
}

throwUnlessCan(...args) {
const rule = this.relevantRuleFor(...args);

if (!rule || rule.inverted) {
const [action, subject, field] = args;
const subjectName = this[PRIVATE_FIELD].subjectName(subject);

throw new ForbiddenError(rule ? rule.reason : null, {
action,
subjectName,
subject,
field
});
}
console.warn(`
Ability.throwUnlessCan is deprecated and will be removed in 4.x version.
Please use "ForbiddenError.from(ability).throwUnlessCan(...)" instead.
`.trim());
ForbiddenError.from(this).throwUnlessCan(...args);
}

on(event, handler) {
Expand Down
79 changes: 63 additions & 16 deletions packages/casl-ability/src/error.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,65 @@
export function ForbiddenError(message, options = {}) {
Error.call(this);
this.constructor = ForbiddenError;
this.subject = options.subject;
this.subjectName = options.subjectName;
this.action = options.action;
this.field = options.field;
this.message = message || `Cannot execute "${this.action}" on "${this.subjectName}"`;

if (typeof Error.captureStackTrace === 'function') {
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
} else {
this.stack = new Error(this.message).stack;
const getDefaultMessage = error => `Cannot execute "${error.action}" on "${error.subjectName}"`;
let defaultErrorMessage = getDefaultMessage;

export class ForbiddenError extends Error {
static setDefaultMessage(messageOrFn) {
if (messageOrFn === null) {
defaultErrorMessage = getDefaultMessage;
} else {
defaultErrorMessage = typeof messageOrFn === 'string' ? () => messageOrFn : messageOrFn;
}
}

static from(ability) {
const error = new this('');
Object.defineProperty(error, 'ability', { value: ability });
return error;
}

constructor(message, options = {}) {
super(message);
this._setMetadata(options);
this.message = message || defaultErrorMessage(this);
this._customMessage = null;

if (typeof Error.captureStackTrace === 'function') {
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

setMessage(message) {
this._customMessage = message;
return this;
}
}

ForbiddenError.prototype = Object.create(Error.prototype);
throwUnlessCan(action, subject, field) {
if (!this.ability) {
throw new ReferenceError('Cannot throw FordiddenError without respective ability instance');
}

const rule = this.ability.relevantRuleFor(action, subject, field);

if (rule && !rule.inverted) {
return;
}

this._setMetadata({
action,
subject,
field,
subjectName: this.ability.subjectName(subject)
});

const reason = rule ? rule.reason : '';
this.message = this._customMessage || reason || defaultErrorMessage(this);
throw this; // eslint-disable-line
}

_setMetadata(options) {
this.subject = options.subject;
this.subjectName = options.subjectName;
this.action = options.action;
this.field = options.field;
}
}

0 comments on commit 4b48562

Please sign in to comment.