Skip to content

Commit

Permalink
perf: rewrite using lua scripting when is possible (#16)
Browse files Browse the repository at this point in the history
* docs: add server example

* test: unify error assertions

* refactor: better error handling

* refactor: extract util
  • Loading branch information
Kikobeats authored Apr 29, 2024
1 parent 15f2420 commit 8841f0f
Show file tree
Hide file tree
Showing 12 changed files with 218 additions and 91 deletions.
62 changes: 62 additions & 0 deletions examples/server/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict'

const { styleText } = require('node:util')
const { json } = require('http-body')
const send = require('send-http')
const Redis = require('ioredis')
const http = require('http')

const redis = new Redis()
const openkey = require('../..')({ redis })

const server = http.createServer(async (req, res) => {
try {
console.log(`~> ${req.method} ${req.url} `)

if (req.url === '/keys/create') {
const options = await json(req)
const result = await openkey.keys.create(options)
return send(res, 201, result)
}

if (req.url.startsWith('/keys/key_')) {
const key = req.url.split('/keys/')[1]
const result = await openkey.keys.retrieve(key)
return send(res, 200, result)
}

if (req.url === '/plans/create') {
const options = await json(req)
const result = await openkey.plans.create(options)
return send(res, 201, result)
}

if (req.url.startsWith('/usage/')) {
const keyValue = req.url.split('/usage/')[1]
const { pending, ...usage } = await openkey.usage.increment(keyValue)
const statusCode = usage.remaining > 0 ? 200 : 429
res.setHeader('X-Rate-Limit-Limit', usage.limit)
res.setHeader('X-Rate-Limit-Remaining', usage.remaining)
res.setHeader('X-Rate-Limit-Reset', usage.reset)
return send(res, statusCode, usage)
}

if (req.url.startsWith('/stats/')) {
const keyValue = req.url.split('/stats/')[1]
const stats = await openkey.stats(keyValue)
return send(res, 200, stats)
}

return send(res, 400)
} catch (error) {
console.log(' ' + styleText('red', `ERROR: ${error.message}`))
res.statusCode = 500
res.end()
}
})

const PORT = 1337

server.listen(PORT, () => {
console.log(`Server is listening on port http://localhost:${PORT}`)
})
5 changes: 5 additions & 0 deletions examples/server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"scripts": {
"dev": "watchexec --on-busy-update=restart --exts js,yml --clear=clear 'node index.js'"
}
}
49 changes: 49 additions & 0 deletions src/error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict'

const { isPlainObject } = require('./util')

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] = args => new OpenKeyError({ code, message: message.apply(null, args()) })
return acc
}, {})

const assert = (condition, code, args = () => []) => {
return (
condition ||
(() => {
throw errors[code](args)
})()
)
}

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
32 changes: 12 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,9 @@ 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}\`.`)
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 +95,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
10 changes: 2 additions & 8 deletions src/stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,11 @@
const { promisify } = require('util')
const stream = require('stream')

const { Transform } = stream
const { formatYYYMMDDDate } = require('./util')

const { Transform } = stream
const pipeline = promisify(stream.pipeline)

const formatYYYMMDDDate = (now = new Date()) => {
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

/**
* 90 days in milliseconds
*/
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
29 changes: 7 additions & 22 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,11 @@ 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())
})()

const assertMetadata = metadata => {
if (metadata) {
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.`)
if (metadata[key] === undefined) delete metadata[key]
})
return Object.keys(metadata).length ? metadata : undefined
}
const formatYYYMMDDDate = (now = new Date()) => {
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

const isPlainObject = value => {
Expand All @@ -52,8 +37,8 @@ const isPlainObject = value => {
}

module.exports = {
assert,
assertMetadata,
formatYYYMMDDDate,
isPlainObject,
pick,
uid
}
22 changes: 14 additions & 8 deletions test/keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ testCleanup({
keys: () => Promise.all([redis.keys(openkey.keys.prefixKey('*'))])
})

test('.create # `metadata` must be a flat object', async t => {
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 @@ -131,11 +135,12 @@ test('.update # add metadata', async t => {
}
})

test('.update # metadata must be a flat object', async t => {
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')
})
Loading

0 comments on commit 8841f0f

Please sign in to comment.