From 4b485620fabb290f0451bc2e828637eaec043c32 Mon Sep 17 00:00:00 2001 From: Sergii Stotskyi Date: Sun, 22 Dec 2019 16:55:59 +0200 Subject: [PATCH] feat(ability): adds possibility to specify custom error messages (#245) Fixes #241 --- .codeclimate.yml | 3 + packages/casl-ability/index.d.ts | 7 ++ packages/casl-ability/spec/ability.spec.js | 35 +--------- packages/casl-ability/spec/error.spec.js | 65 ++++++++++++++++++ packages/casl-ability/src/ability.js | 26 +++---- packages/casl-ability/src/error.js | 79 +++++++++++++++++----- 6 files changed, 148 insertions(+), 67 deletions(-) create mode 100644 packages/casl-ability/spec/error.spec.js diff --git a/.codeclimate.yml b/.codeclimate.yml index f66ccccbe..ad6066b2f 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -7,6 +7,9 @@ plugins: enabled: false import/no-extraneous-dependencies: enabled: false + no-console: + enabled: false + exclude_patterns: - docs/**/* - "**/spec/**/*" diff --git a/packages/casl-ability/index.d.ts b/packages/casl-ability/index.d.ts index 122aae4d1..33a054e9e 100644 --- a/packages/casl-ability/index.d.ts +++ b/packages/casl-ability/index.d.ts @@ -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 } diff --git a/packages/casl-ability/spec/ability.spec.js b/packages/casl-ability/spec/ability.spec.js index 1bc8f950a..b01e32683 100755 --- a/packages/casl-ability/spec/ability.spec.js +++ b/packages/casl-ability/spec/ability.spec.js @@ -1,4 +1,4 @@ -import { AbilityBuilder, ForbiddenError, Ability } from '../src' +import { AbilityBuilder, Ability } from '../src' import { Post } from './spec_helper' describe('Ability', () => { @@ -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 diff --git a/packages/casl-ability/spec/error.spec.js b/packages/casl-ability/spec/error.spec.js new file mode 100644 index 000000000..b45501638 --- /dev/null +++ b/packages/casl-ability/spec/error.spec.js @@ -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') + }) + }) +}) diff --git a/packages/casl-ability/src/ability.js b/packages/casl-ability/src/ability.js index 97b16ca74..e4b667b4b 100755 --- a/packages/casl-ability/src/ability.js +++ b/packages/casl-ability/src/ability.js @@ -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), @@ -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}`; @@ -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) { diff --git a/packages/casl-ability/src/error.js b/packages/casl-ability/src/error.js index 0a1b7b93a..90b80a79f 100644 --- a/packages/casl-ability/src/error.js +++ b/packages/casl-ability/src/error.js @@ -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; + } +}