Skip to content

Commit

Permalink
refactor: better error handling
Browse files Browse the repository at this point in the history
  • Loading branch information
Kikobeats committed Apr 29, 2024
1 parent 16b9f80 commit bb6c685
Show file tree
Hide file tree
Showing 8 changed files with 141 additions and 52 deletions.
56 changes: 56 additions & 0 deletions src/error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use strict'

const isPlainObject = value => {
if (!value || typeof value !== 'object' || value.toString() !== '[object Object]') {
return false
}

const prototype = Object.getPrototypeOf(value)
return prototype === null || prototype === Object.prototype
}

class OpenKeyError extends Error {
constructor (props) {
super()
this.name = 'OpenKeyError'
Object.assign(this, props)
}
}

const errors = [
['KEY_NOT_EXIST', key => `The key \`${key}\` does not exist.`],
['KEY_IS_ASSOCIATED', (id, value) => `The plan \`${id}\` is associated with the key \`${value}\`.`],
['PLAN_NOT_EXIST', plan => `The plan \`${plan}\` does not exist.`],
['PLAN_ID_REQUIRED', () => 'The argument `id` must be a string.'],
['PLAN_INVALID_ID', () => 'The argument `id` cannot contain whitespace.'],
['PLAN_INVALID_LIMIT', () => 'The argument `limit` must be a positive number.'],
['PLAN_INVALID_PERIOD', () => 'The argument `period` must be a string.'],
['PLAN_ALREADY_EXIST', plan => `The plan \`${plan}\` already exists.`],
['METADATA_NOT_FLAT_OBJECT', () => 'The metadata must be a flat object.'],
['METADATA_INVALID', key => `The metadata field '${key}' can't be an object.`]
].reduce((acc, [code, message]) => {
acc[code] = (...input) => new OpenKeyError({ code, message: message(...input) })
return acc
}, {})

const assert = (condition, code, ...opts) => {
return (
condition ||
(() => {
throw errors[code].apply(null, opts)
})()
)
}

const assertMetadata = metadata => {
if (metadata) {
assert(isPlainObject(metadata), 'METADATA_NOT_FLAT_OBJECT')
Object.keys(metadata).forEach(key => {
assert(!isPlainObject(metadata[key]), 'METADATA_INVALID', key)
if (metadata[key] === undefined) delete metadata[key]
})
return Object.keys(metadata).length ? metadata : undefined
}
}

module.exports = { errors, assert, assertMetadata }
7 changes: 4 additions & 3 deletions src/keys.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

const { pick, uid, assert, assertMetadata } = require('./util')
const { pick, uid } = require('./util')
const { assert, assertMetadata } = require('./error')

module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => {
/**
Expand Down Expand Up @@ -40,7 +41,7 @@ module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => {
*/
const retrieve = async (value, { throwError = false } = {}) => {
const key = await redis.get(prefixKey(value))
if (throwError) assert(key !== null, () => `The key \`${value}\` does not exist.`)
if (throwError) assert(key !== null, 'KEY_NOT_EXIST', value)
else if (key === null) return null
return Object.assign({ value }, deserialize(key))
}
Expand All @@ -54,7 +55,7 @@ module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => {
*/
const del = async value => {
const isDeleted = (await redis.del(prefixKey(value))) === 1
assert(isDeleted, () => `The key \`${value}\` does not exist.`)
assert(isDeleted, 'KEY_NOT_EXIST', value)
return isDeleted
}

Expand Down
33 changes: 13 additions & 20 deletions src/plans.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

const { pick, assert, assertMetadata } = require('./util')
const { pick } = require('./util')
const { assert, assertMetadata } = require('./error')

module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
/**
Expand All @@ -17,26 +18,20 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
* @returns {Object} The plan object.
*/
const create = async (opts = {}) => {
assert(typeof opts.id === 'string' && opts.id.length > 0, () => 'The argument `id` must be a string.')
assert(!/\s/.test(opts.id), () => 'The argument `id` cannot contain whitespace.')
assert(typeof opts.id === 'string' && opts.id.length > 0, 'PLAN_ID_REQUIRED')
assert(!/\s/.test(opts.id), 'PLAN_INVALID_ID')
const plan = Object.assign(
{
limit: assert(
typeof opts.limit === 'number' && opts.limit > 0 && opts.limit,
() => 'The argument `limit` must be a positive number.'
),
period: assert(
typeof opts.period === 'string' && opts.period.length > 0 && opts.period,
() => 'The argument `period` must be a string.'
)
limit: assert(typeof opts.limit === 'number' && opts.limit > 0 && opts.limit, 'PLAN_INVALID_LIMIT'),
period: assert(typeof opts.period === 'string' && opts.period.length > 0 && opts.period, 'PLAN_INVALID_PERIOD')
},
pick(opts, ['rate', 'burst'])
)
const metadata = assertMetadata(opts.metadata)
if (metadata) plan.metadata = metadata
plan.createdAt = plan.updatedAt = Date.now()
const isCreated = (await redis.set(prefixKey(opts.id), serialize(plan), 'NX')) === 'OK'
if (!isCreated) throw new TypeError(`The plan \`${opts.id}\` already exists.`)
assert(isCreated, 'PLAN_ALREADY_EXIST', opts.id)
return Object.assign({ id: opts.id }, plan)
}

Expand All @@ -52,7 +47,7 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
*/
const retrieve = async (id, { throwError = false } = {}) => {
const plan = await redis.get(prefixKey(id))
if (throwError) assert(plan !== null, () => `The plan \`${id}\` does not exist.`)
if (throwError) assert(plan !== null, 'PLAN_NOT_EXIST', id)
else if (plan === null) return null
return Object.assign({ id }, deserialize(plan))
}
Expand All @@ -68,9 +63,10 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
const del = async id => {
const allKeys = await keys().list()
const key = allKeys.find(key => key.plan === id)
assert(key === undefined, () => `The plan \`${id}\` is associated with the key \`${key.value}\`.`)
// TODO: try to improve this
assert(key === undefined, 'KEY_IS_ASSOCIATED', id, key?.value)
const isDeleted = (await redis.del(prefixKey(id))) === 1
assert(isDeleted, () => `The plan \`${id}\` does not exist.`)
assert(isDeleted, 'PLAN_NOT_EXIST', id)
return isDeleted
}

Expand Down Expand Up @@ -100,16 +96,13 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
)

if (opts.limit) {
plan.limit = assert(
typeof opts.limit === 'number' && opts.limit > 0 && opts.limit,
() => 'The argument `limit` must be a positive number.'
)
plan.limit = assert(typeof opts.limit === 'number' && opts.limit > 0 && opts.limit, 'PLAN_INVALID_LIMIT')
}

if (opts.period) {
plan.period = assert(
typeof opts.period === 'string' && opts.period.length > 0 && opts.period,
() => 'The argument `period` must be a string.'
'PLAN_INVALID_PERIOD'
)
}

Expand Down
12 changes: 7 additions & 5 deletions src/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ module.exports = ({ plans, keys, redis, stats, prefix, serialize, deserialize })
* Increment the usage for a given key.
*
* @param {string} keyValue
* @param {number} [quantity=1]
* @param {Object} options - The options for creating a plan.
* @param {number} [options.quantity=1] - The quantity to increment.
* @param {boolean} [options.throwError=true] - Throw an error if the key does not exist.
*
* @returns {Promise<{limit: number, remaining: number, reset: number, pending: Promise<void>}>}
*
Expand All @@ -23,9 +25,9 @@ module.exports = ({ plans, keys, redis, stats, prefix, serialize, deserialize })
* // }
*
*/
const increment = async (keyValue, quantity = 1) => {
const key = await keys.retrieve(keyValue)
const plan = await plans.retrieve(key.plan)
const increment = async (keyValue, { quantity = 1, throwError = true } = {}) => {
const key = await keys.retrieve(keyValue, { throwError })
const plan = await plans.retrieve(key.plan, { throwError })

let usage = deserialize(await redis.get(prefixKey(keyValue)))

Expand Down Expand Up @@ -73,7 +75,7 @@ module.exports = ({ plans, keys, redis, stats, prefix, serialize, deserialize })
* // }
*
*/
const get = async keyValue => increment(keyValue, 0)
const get = async keyValue => increment(keyValue, { quantity: 0 })
get.increment = increment
get.prefixKey = prefixKey

Expand Down
18 changes: 12 additions & 6 deletions test/keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ testCleanup({
test('.create # error if `metadata` is not a flat object', async t => {
const error = await t.throwsAsync(openkey.keys.create({ metadata: { tier: { type: 'new' } } }))
t.is(error.message, "The metadata field 'tier' can't be an object.")
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'METADATA_INVALID')
})

test('.create # `metadata` as undefined is omitted', async t => {
Expand All @@ -31,7 +32,8 @@ test('.create # `metadata` as undefined is omitted', async t => {
test('.create # error if plan does not exist', async t => {
const error = await t.throwsAsync(openkey.keys.create({ plan: '123' }))
t.is(error.message, 'The plan `123` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_NOT_EXIST')
})

test('.create', async t => {
Expand Down Expand Up @@ -94,14 +96,16 @@ test('.update', async t => {
test('.update # error if key does not exist', async t => {
const error = await t.throwsAsync(openkey.keys.update('value'))
t.is(error.message, 'The key `value` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'KEY_NOT_EXIST')
})

test('.update # error if plan does not exist', async t => {
const { value } = await openkey.keys.create()
const error = await t.throwsAsync(openkey.keys.update(value, { plan: 'id' }))
t.is(error.message, 'The plan `id` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_NOT_EXIST')
})

test('.update # add a plan', async t => {
Expand Down Expand Up @@ -135,7 +139,8 @@ test('.update # error is metadata is not a flat object', async t => {
const { value } = await openkey.keys.create()
const error = await t.throwsAsync(openkey.keys.update(value, { metadata: { email: { cc: '[email protected]' } } }))
t.is(error.message, "The metadata field 'email' can't be an object.")
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'METADATA_INVALID')
})

test('.update # metadata as undefined is omitted', async t => {
Expand Down Expand Up @@ -192,5 +197,6 @@ test('.del # error if key does not exist', async t => {
const error = await t.throwsAsync(openkey.keys.del('key_id'))

t.is(error.message, 'The key `key_id` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'KEY_NOT_EXIST')
})
48 changes: 32 additions & 16 deletions test/plans.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,38 @@ test('.create # error is `id` is not provided', async t => {
{
const error = await t.throwsAsync(openkey.plans.create())
t.is(error.message, 'The argument `id` must be a string.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_ID_REQUIRED')
}
{
const error = await t.throwsAsync(openkey.plans.create({ id: null }))
t.is(error.message, 'The argument `id` must be a string.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_ID_REQUIRED')
}
{
const error = await t.throwsAsync(openkey.plans.create({ id: undefined }))
t.is(error.message, 'The argument `id` must be a string.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_ID_REQUIRED')
}
{
const error = await t.throwsAsync(openkey.plans.create({ id: 0 }))
t.is(error.message, 'The argument `id` must be a string.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_ID_REQUIRED')
}
{
const error = await t.throwsAsync(openkey.plans.create({ id: NaN }))
t.is(error.message, 'The argument `id` must be a string.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_ID_REQUIRED')
}
{
const error = await t.throwsAsync(openkey.plans.create({ id: false }))
t.is(error.message, 'The argument `id` must be a string.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_ID_REQUIRED')
}
})

Expand All @@ -57,25 +63,29 @@ test('.create # error if `id` already exist', async t => {
t.is(typeof plan, 'object')
const error = await t.throwsAsync(openkey.plans.create(props))
t.is(error.message, `The plan \`${id}\` already exists.`)
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_ALREADY_EXIST')
})

test('.create # error if `id` contains whitespaces', async t => {
const error = await t.throwsAsync(openkey.plans.create({ id: 'free tier' }))
t.is(error.message, 'The argument `id` cannot contain whitespace.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_INVALID_ID')
})

test('.create # error if `limit` is not provided', async t => {
const error = await t.throwsAsync(openkey.plans.create({ id: randomUUID() }))
t.is(error.message, 'The argument `limit` must be a positive number.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_INVALID_LIMIT')
})

test('.create # error if `period` is not provided', async t => {
const error = await t.throwsAsync(openkey.plans.create({ id: randomUUID(), limit: 3 }))
t.is(error.message, 'The argument `period` must be a string.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_INVALID_PERIOD')
})

test('.create # error if `metadata` is not a flat object', async t => {
Expand All @@ -89,7 +99,8 @@ test('.create # error if `metadata` is not a flat object', async t => {
})
)
t.is(error.message, "The metadata field 'tier' can't be an object.")
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'METADATA_INVALID')
}
{
const error = await t.throwsAsync(
Expand All @@ -101,7 +112,8 @@ test('.create # error if `metadata` is not a flat object', async t => {
})
)
t.is(error.message, 'The metadata must be a flat object.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'METADATA_NOT_FLAT_OBJECT')
}
})

Expand Down Expand Up @@ -273,7 +285,8 @@ test('.update # error is metadata is not a flat object', async t => {

const error = await t.throwsAsync(openkey.plans.update(id, { metadata: { tier: { type: 'new' } } }))
t.is(error.message, "The metadata field 'tier' can't be an object.")
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'METADATA_INVALID')
})

test('.update # metadata as undefined is omitted', async t => {
Expand Down Expand Up @@ -326,7 +339,8 @@ test('.update # prevent to modify the plan id', async t => {
test('.update # error if plan does not exist', async t => {
const error = await t.throwsAsync(openkey.plans.update('plan_id', { foo: 'bar' }))
t.is(error.message, 'The plan `plan_id` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_NOT_EXIST')
})

test.serial('.list', async t => {
Expand Down Expand Up @@ -367,7 +381,8 @@ test('.del', async t => {
test('.del # error if plan does not exist', async t => {
const error = await t.throwsAsync(openkey.plans.del('id'))
t.is(error.message, 'The plan `id` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_NOT_EXIST')
})

test.serial('.del # error if a key is associated with the plan', async t => {
Expand All @@ -381,5 +396,6 @@ test.serial('.del # error if a key is associated with the plan', async t => {
const error = await t.throwsAsync(openkey.plans.del(plan.id))

t.is(error.message, `The plan \`${plan.id}\` is associated with the key \`${key.value}\`.`)
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'KEY_IS_ASSOCIATED')
})
Loading

0 comments on commit bb6c685

Please sign in to comment.