Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: remove unnecessary props #13

Merged
merged 1 commit into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ const JSONB = require('json-buffer')
const createKeys = require('./keys')
const createPlans = require('./plans')

module.exports = ({ serialize = JSONB.stringify, deserialize = JSONB.parse, redis = new Map() } = {}) => {
const plans = createPlans({ serialize, deserialize, redis })
const keys = createKeys({ serialize, deserialize, redis, plans })
module.exports = ({ serialize = JSONB.stringify, deserialize = JSONB.parse, redis = new Map(), prefix = '' } = {}) => {
let _keys
const plans = createPlans({ serialize, deserialize, redis, prefix, keys: () => _keys })
const keys = (_keys = createKeys({ serialize, deserialize, redis, plans, prefix }))
return { keys, plans }
}
89 changes: 35 additions & 54 deletions src/keys.js
Original file line number Diff line number Diff line change
@@ -1,98 +1,80 @@
'use strict'

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

const KEY_PREFIX = 'key_'
const KEY_FIELDS = ['name', 'description', 'enabled', 'value', 'plan']
const KEY_FIELDS_OBJECT = ['metadata']

module.exports = ({ serialize, deserialize, plans, redis } = {}) => {
module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => {
/**
* Create a key.
*
* @param {Object} options - The options for creating a plan.
* @param {string} options.name - The name of the key.
* @param {string} [options.value] - The value of the key.
* @param {string} [options.plan] - The id of the plan associated.
* @param {string} [options.description] - The description of the key.
* @param {string} [options.enabled] - Whether the key is enabled or not.
* @param {Object} [options.metadata] - Any extra information can be attached here.
*
* @returns {Object} The created key.
*/
const create = async (opts = {}) => {
assert(typeof opts.name === 'string' && opts.name.length > 0, 'The argument `name` is required.')
opts.metadata = assertMetadata(opts.metadata)
const key = pick(opts, KEY_FIELDS.concat(KEY_FIELDS_OBJECT))
key.id = await uid({ redis, prefix: KEY_PREFIX, size: 5 })
const key = { enabled: opts.enabled ?? true }
const metadata = assertMetadata(opts.metadata)
if (metadata) key.metadata = metadata
key.createdAt = key.updatedAt = Date.now()
key.value = await uid({ redis, size: 16 })
if (key.enabled === undefined) key.enabled = true
if (opts.plan) await plans.retrieve(opts.plan, { throwError: true })
return (await redis.setnx(key.id, serialize(key))) && key
const value = opts.value ?? (await uid({ redis, size: 16 }))
if (opts.plan) {
await plans.retrieve(opts.plan, { throwError: true })
key.plan = opts.plan
}
await redis.set(prefixKey(value), serialize(key), 'NX')
return Object.assign({ value }, key)
}

/**
* Retrieve a key by id.
* Retrieve a key by value.
*
* @param {string} keyId - The id of the key.
* @param {string} value - The value of the key.
* @param {Object} [options] - The options for retrieving a key.
* @param {boolean} [options.validate=true] - Validate if the plan id is valid.
* @param {boolean} [options.throwError=false] - Throw an error if the plan does not exist.
*
* @returns {Object|null} The key object, null if it doesn't exist.
*/
const retrieve = async (keyId, { throwError = false, validate = true } = {}) => {
const key = await redis.get(getKey(keyId, { validate }))
if (throwError) {
assert(key !== null, `The key \`${keyId}\` does not exist.`)
}
return deserialize(key)
const retrieve = async (value, { throwError = false } = {}) => {
const key = await redis.get(prefixKey(value))
if (throwError) assert(key !== null, () => `The key \`${value}\` does not exist.`)
else if (key === null) return null
return Object.assign({ value }, deserialize(key))
}

/**
* Delete a key by id.
* Delete a key by value.
*
* @param {string} keyId - The id of the key.
* @param {string} value - The value of the key.
*
* @returns {boolean} Whether the key was deleted or not.
*/
const del = async keyId => {
const key = await retrieve(keyId, { verify: true })
if (key !== null && typeof key.plan === 'string') {
const plan = await plans.retrieve(key.plan, {
throwError: true,
validate: false
})
assert(plan === null, `The key \`${keyId}\` is associated with the plan \`${getKey.plan}\``)
}
const isDeleted = (await redis.del(getKey(keyId, { verify: true }))) === 1
assert(isDeleted, `The key \`${keyId}\` does not exist.`)
const del = async value => {
const isDeleted = (await redis.del(prefixKey(value))) === 1
assert(isDeleted, () => `The key \`${value}\` does not exist.`)
return isDeleted
}

/**
* Update a key by id.
* Update a key by value.
*
* @param {string} keyId - The id of the plan.
* @param {string} value - The value of the plan.
* @param {Object} options - The options for updating a plan.
* @param {string} [options.name] - The name of the key.
* @param {string} [options.value] - The value of the key.
* @param {string} [options.description] - The description of the key.
* @param {string} [options.enabled] - Whether the key is enabled or not.
* @param {object} [options.metadata] - Any extra information can be attached here.
*
* @returns {Object} The updated plan.
*/
const update = async (keyId, opts) => {
const currentKey = await retrieve(keyId, { throwError: true })
const update = async (value, opts) => {
const currentKey = await retrieve(value, { throwError: true })
const metadata = Object.assign({}, currentKey.metadata, assertMetadata(opts.metadata))
const key = Object.assign(currentKey, pick(opts, KEY_FIELDS), {
updatedAt: Date.now()
})
const key = Object.assign(currentKey, { updatedAt: Date.now() }, pick(opts, ['enabled', 'value', 'plan']))
if (Object.keys(metadata).length) key.metadata = metadata
if (key.plan) await plans.retrieve(key.plan, { throwError: true })
return (await redis.set(keyId, serialize(key))) && key
return (await redis.set(prefixKey(value), serialize(key))) && key
}

/**
Expand All @@ -101,13 +83,12 @@ module.exports = ({ serialize, deserialize, plans, redis } = {}) => {
* @returns {Array} The list of keys.
*/
const list = async () => {
const keyIds = await redis.keys(`${KEY_PREFIX}*`)
return Promise.all(keyIds.map(keyIds => retrieve(keyIds, { validate: false })))
const allKeys = await redis.keys(prefixKey('*'))
const keyValues = allKeys.map(key => key.replace(prefixKey(''), ''))
return Promise.all(keyValues.map(keyValues => retrieve(keyValues)))
}

const getKey = validateKey({ prefix: KEY_PREFIX })
const prefixKey = key => `${prefix}key_${key}`

return { create, retrieve, del, update, list }
return { create, retrieve, del, update, list, prefixKey }
}

module.exports.KEY_PREFIX = KEY_PREFIX
133 changes: 75 additions & 58 deletions src/plans.js
Original file line number Diff line number Diff line change
@@ -1,102 +1,119 @@
'use strict'

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

const PLAN_PREFIX = 'plan_'
const PLAN_QUOTA_PERIODS = ['day', 'week', 'month']
const PLAN_FIELDS = ['name', 'description']
const PLAN_FIELDS_OBJECT = ['quota', 'throttle', 'metadata']

module.exports = ({ serialize, deserialize, redis } = {}) => {
module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
/**
* Create a plan.
*
* @param {Object} options - The options for creating a plan.
* @param {string} options.name - The name of the plan.
* @param {string} [options.description] - The description of the plan.
* @param {number} [options.quota] - The quota of the plan.
* @param {string} [options.quota.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH".
* @param {number} [options.quota.limit] - The target maximum number of requests that can be made in a given time period.
* @param {Object} [options.throttle] - The throttle of the plan.
* @param {number} [options.throttle.burstLimit] - The burst limit of the plan.
* @param {number} [options.throttle.rateLimit] - The rate limit of the plan.
* @param {string} options.id - The id of the plan.
* @param {number} [options.limit] - The target maximum number of requests that can be made in a given time period.
* @param {string} [options.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH".
* @param {number} [options.burst] - The burst limit of the plan.
* @param {number} [options.rate] - The rate limit of the plan.
* @param {Object} [options.metadata] - Any extra information can be attached here.
*
* @returns {Object} The plan object.
*/
const create = async (opts = {}) => {
assert(typeof opts.name === 'string' && opts.name.length > 0, 'The argument `name` is required.')
assert(
PLAN_QUOTA_PERIODS.includes(opts.quota?.period),
`The argument \`quota.period\` must be ${PLAN_QUOTA_PERIODS.map(period => `\`${period}\``).join(' or ')}.`
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.')
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.'
)
},
pick(opts, ['rate', 'burst'])
)
assert(opts.quota.limit > 0, 'The argument `quota.limit` must be a positive number.')
opts.metadata = assertMetadata(opts.metadata)
const plan = pick(opts, PLAN_FIELDS.concat(PLAN_FIELDS_OBJECT))
plan.id = await uid({ redis, prefix: PLAN_PREFIX, size: 5 })
const metadata = assertMetadata(opts.metadata)
if (metadata) plan.metadata = metadata
plan.createdAt = plan.updatedAt = Date.now()
return (await redis.setnx(plan.id, serialize(plan))) && plan
await redis.set(prefixKey(opts.id), serialize(plan), 'NX')
return Object.assign({ id: opts.id }, plan)
}

/**
* Retrieve a plan by id
*
* @param {string} planId - The id of the plan.
* @param {string} id - The id of the plan.
* @param {Object} [options] - The options for retrieving a plan.
* @param {boolean} [options.validate=true] - Validate if the plan id is valid.
* @param {boolean} [options.throwError=false] - Throw an error if the plan does not exist.
*
* @returns {Object} The plan.
*/
const retrieve = async (planId, { throwError = false, validate = true } = {}) => {
const plan = await redis.get(getKey(planId, { validate }))
if (throwError) {
assert(plan !== null, `The plan \`${planId}\` does not exist.`)
}
return deserialize(plan)
const retrieve = async (id, { throwError = false } = {}) => {
const plan = await redis.get(prefixKey(id))
if (throwError) assert(plan !== null, () => `The plan \`${id}\` does not exist.`)
else if (plan === null) return null
return Object.assign({ id }, deserialize(plan))
}

/**
* Delete a plan by id.
*
* @param {string} planId - The id of the plan.
* @param {string} id - The id of the plan.
* @param {Object} [options] - The options for deleting a plan.
*
* @returns {boolean} Whether the plan was deleted or not.
*/
const del = async planId => {
const isDeleted = (await redis.del(getKey(planId, { validate: true }))) === 1
assert(isDeleted, `The plan \`${planId}\` does not exist.`)
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}\`.`)
const isDeleted = (await redis.del(prefixKey(id))) === 1
assert(isDeleted, () => `The plan \`${id}\` does not exist.`)
return isDeleted
}

/**
* Update a plan by id.
*
* @param {string} planId - The id of the plan.
* @param {string} id - The id of the plan.
* @param {Object} options - The options for updating a plan.
* @param {string} [options.name] - The name of the plan.
* @param {string} [options.description] - The description of the plan.
* @param {number} [options.quota] - The quota of the plan.
* @param {string} [options.quota.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH".
* @param {number} [options.quota.limit] - The target maximum number of requests that can be made in a given time period.
* @param {Object} [options.throttle] - The throttle of the plan.
* @param {number} [options.throttle.burstLimit] - The burst limit of the plan.
* @param {number} [options.throttle.rateLimit] - The rate limit of the plan.
* @param {number} [options.limit] - The target maximum number of requests that can be made in a given time period.
* @param {string} [options.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH".
* @param {number} [options.burst] - The burst limit of the plan.
* @param {number} [options.rate] - The rate limit of the plan.
* @param {object} [options.metadata] - Any extra information can be attached here.
*
* @returns {Object} The updated plan.
*/
const update = async (planId, opts) => {
const currentPlan = await retrieve(planId, { throwError: true })
const quota = Object.assign(currentPlan.quota, opts.quota)
const update = async (id, opts) => {
const currentPlan = await retrieve(id, { throwError: true })
const metadata = Object.assign({}, currentPlan.metadata, assertMetadata(opts.metadata))
const plan = Object.assign(currentPlan, pick(opts, PLAN_FIELDS), {
quota,
updatedAt: Date.now()
})

const plan = Object.assign(
currentPlan,
{
updatedAt: Date.now()
},
pick(opts, ['rate', 'burst'])
)

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

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

if (Object.keys(metadata).length) plan.metadata = metadata
return (await redis.set(planId, serialize(plan))) && plan
return (await redis.set(prefixKey(id), serialize(plan))) && plan
}

/**
Expand All @@ -105,13 +122,13 @@ module.exports = ({ serialize, deserialize, redis } = {}) => {
* @returns {Array} The list of plans.
*/
const list = async () => {
const planIds = await redis.keys(`${PLAN_PREFIX}*`)
return Promise.all(planIds.map(planId => retrieve(planId, { validate: false })))
const allPlans = await redis.keys(prefixKey('*'))
const planIds = allPlans.map(key => key.replace(prefixKey(''), ''))
const result = await Promise.all(planIds.map(planId => retrieve(planId)))
return result
}

const getKey = validateKey({ prefix: PLAN_PREFIX })
const prefixKey = key => `${prefix}plan_${key}`

return { create, del, retrieve, update, list }
return { create, del, retrieve, update, list, prefixKey }
}

module.exports.PLAN_PREFIX = PLAN_PREFIX
24 changes: 9 additions & 15 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,22 @@ const pick = (obj, keys) => {
return result
}

/**
* Assert a condition, or throw an error if the condition is falsy.
* @param {*} value - The value to assert.
* @param {string} message - The error message.
*/
const assert = (value, message) =>
value ||
(() => {
throw new TypeError(message)
throw new TypeError(message())
})()

const validateKey =
({ prefix }) =>
(id, { validate = true } = {}) => {
if (!validate) return id
if (!String(id).startsWith(prefix)) {
throw new TypeError(`The id \`${id}\` must to start with \`${prefix}\`.`)
}
return id
}

const assertMetadata = metadata => {
if (metadata) {
assert(isPlainObject(metadata), 'The metadata must be a flat object.')
assert(isPlainObject(metadata), () => 'The metadata must be a flat object.')
Object.keys(metadata).forEach(key => {
assert(!isPlainObject(metadata[key]), `The metadata field '${key}' can't be an object.`)
assert(!isPlainObject(metadata[key]), () => `The metadata field '${key}' can't be an object.`)
if (metadata[key] === undefined) delete metadata[key]
})
return Object.keys(metadata).length ? metadata : undefined
Expand All @@ -60,6 +55,5 @@ module.exports = {
assert,
assertMetadata,
pick,
uid,
validateKey
uid
}
Loading
Loading