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

chore: associate keys with a plan #5

Merged
merged 3 commits into from
Jan 12, 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
71 changes: 71 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: main

on:
push:
branches:
- master

jobs:
contributors:
if: "${{ github.event.head_commit.message != 'build: contributors' }}"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Contributors
run: |
git config --global user.email ${{ secrets.GIT_EMAIL }}
git config --global user.name ${{ secrets.GIT_USERNAME }}
npm run contributors
- name: Push changes
run: |
git push origin ${{ github.head_ref }}

release:
if: |
!startsWith(github.event.head_commit.message, 'chore(release):') &&
!startsWith(github.event.head_commit.message, 'docs:') &&
!startsWith(github.event.head_commit.message, 'ci:')
needs: [contributors]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: latest
run_install: true
- name: Start Redis
uses: supercharge/[email protected]
- name: Test
run: pnpm test
# - name: Report
# run: npx c8 report --reporter=text-lcov > coverage/lcov.info
# - name: Coverage
# uses: coverallsapp/github-action@main
# with:
# github-token: ${{ secrets.GITHUB_TOKEN }}
# - name: Release
# env:
# GH_TOKEN: ${{ secrets.GH_TOKEN }}
# NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# run: |
# git config --global user.email ${{ secrets.GIT_EMAIL }}
# git config --global user.name ${{ secrets.GIT_USERNAME }}
# git pull origin master
# pnpm run release
38 changes: 38 additions & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: pull_request

on:
push:
branches:
- master
pull_request:
branches:
- master

jobs:
test:
if: github.ref != 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: latest
run_install: true
- name: Start Redis
uses: supercharge/[email protected]
- name: Test
run: pnpm test
# - name: Report
# run: npx c8 report --reporter=text-lcov > coverage/lcov.info
# - name: Coverage
# uses: coverallsapp/github-action@main
# with:
# github-token: ${{ secrets.GITHUB_TOKEN }}
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@

## License

## FAQ

### Why?

Until **openkey**, we use AWS Gateway feature for keys and plans for years. Although it was doing the job, it forced us to send all [api.microlink.io](https://api.microlink.io) traffic to AWS first for the control traffic, and then travel back to origin servers. We wanted to avoid that hop to provide a more responsive service.

### Is it a AWS Gateway replacement?

No, and we are not aspiring for it. We used to use a very specific feature present in the AWS Gateway service, and **openkey** is a replacement for that features, that's all. AWS Gateway can still do a lot of more things that this library.

### Why Redis?

We needed a backend layer fast for frequent writes, cheap at scale and mature enough for preventing vendor-lock in. We considered other alternatives such as SQLite, but according with this requeriments Redis is the no brain selection.

### Why key/value?

Originally this library was implemented using [hashes](https://redis.io/docs/data-types/hashes), but then since values are stored as string, it's necessary to cast value (for example, from string to number). Since we need to do that all the time, we prefer to use key/value in combination with JSON.parse/JSON.stringify. It makes the code tinier and easier to read.

**openkey** © [microlink.io](https://microlink.io), released under the [MIT](https://github.com/microlinkhq/openkey/blob/master/LICENSE.md) License.<br>
Authored and maintained by [microlink.io](https://microlink.io) with help from [contributors](https://github.com/microlinkhq/openkey/contributors).

Expand Down
26 changes: 1 addition & 25 deletions bin/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
#!/usr/bin/env node
'use strict'

const path = require('path')
const pkg = require('../package.json')
const JoyCon = require('joycon')
const mri = require('mri')

require('update-notifier')({ pkg }).notify()

const { _, ...flags } = mri(process.argv.slice(2), {
/* https://github.com/lukeed/mri#usage< */
default: {
Expand All @@ -20,26 +15,7 @@ if (flags.help) {
process.exit(0)
}

const joycon = new JoyCon({
cwd,
packageKey: pkg.name,
files: [
'package.json',
`.${pkg.name}rc`,
`.${pkg.name}rc.json`,
`.${pkg.name}rc.js`,
`${pkg.name}.config.js`
]
})

const { data: config = {} } = (await joycon.load()) || {}

Promise.resolve(
require('openkey')({
...config,
...flags
})
)
Promise.resolve(require('openkey')(flags))
.then(() => {
process.exit(0)
})
Expand Down
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
"openkey": "bin/index.js"
},
"author": {
"email": "[email protected]",
"name": "microlink.io",
"email": "[email protected]",
"url": "https://microlink.io"
},
"repository": {
Expand All @@ -28,16 +28,20 @@
"dependencies": {
"mri": "~1.2.0"
},
"files": [
"src"
],
"devDependencies": {
"ioredis": "latest",
"@commitlint/cli": "latest",
"@commitlint/config-conventional": "latest",
"@ksmithut/prettier-standard": "latest",
"ava": "5",
"c8": "latest",
"ci-publish": "latest",
"github-generate-release": "latest",
"finepack": "latest",
"git-authors-cli": "latest",
"github-generate-release": "latest",
"nano-staged": "latest",
"npm-check-updates": "latest",
"simple-git-hooks": "latest",
Expand All @@ -53,7 +57,7 @@
"contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
"coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
"lint": "standard-markdown README.md && standard",
"postrelease": "npm run release:tags && npm run release:github && npm publish",
"postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)",
"prerelease": "npm run update:check",
"pretest": "npm run lint",
"release": "standard-version -a",
Expand Down
7 changes: 3 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ export default ({
redis = new Map()
} = {}) => {
if (!redis) throw TypeError('The argument `store` is required.')
return {
keys: createKeys({ serialize, deserialize, redis }),
plans: createPlans({ serialize, deserialize, redis })
}
const plans = createPlans({ serialize, deserialize, redis })
const keys = createKeys({ serialize, deserialize, redis, plans })
return { keys, plans }
}
45 changes: 35 additions & 10 deletions src/keys.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { pick, uid, validateKey } from './util.js'

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

export default ({ serialize, deserialize, redis } = {}) => {
export default ({ serialize, deserialize, plans, redis } = {}) => {
/**
* 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.
Expand All @@ -25,6 +26,7 @@ export default ({ serialize, deserialize, redis } = {}) => {
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 })
await redis.setnx(key.id, serialize(key))
return key
}
Expand All @@ -33,11 +35,20 @@ export default ({ serialize, deserialize, redis } = {}) => {
* Retrieve a key by id.
*
* @param {string} keyId - The id 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} The key.
*/
const retrieve = async (keyId, opts) =>
deserialize(await redis.get(key(keyId, opts)))
const retrieve = async (
keyId,
{ throwError = false, validate = true } = {}
) => {
const key = await redis.get(getKey(keyId, { validate }))
if (key === null && throwError) { throw new TypeError(`The key \`${keyId}\` does not exist.`) }
return deserialize(key)
}

/**
* Delete a key by id.
Expand All @@ -46,8 +57,19 @@ export default ({ serialize, deserialize, redis } = {}) => {
*
* @returns {boolean} Whether the key was deleted or not.
*/
const del = async (keyId, opts) => {
const isDeleted = Boolean(await redis.del(key(keyId, opts)))
const del = async keyId => {
const key = await retrieve(keyId, { verify: true })

if (key !== null && key.plan) {
const plan = await plans.retrieve(key.plan, { throwError: true, validate: false })
if (plan !== null) {
throw new TypeError(
`The key \`${keyId}\` is associated with the plan \`${getKey.plan}\``
)
}
}

const isDeleted = Boolean(await redis.del(getKey(keyId, { verify: true })))
if (!isDeleted) throw new TypeError(`The key \`${keyId}\` does not exist.`)
return isDeleted
}
Expand All @@ -66,11 +88,14 @@ export default ({ serialize, deserialize, redis } = {}) => {
* @returns {Object} The updated plan.
*/
const update = async (keyId, opts) => {
const currentKey = await retrieve(keyId)
const currentKey = await retrieve(keyId, { throwError: true })
if (!currentKey) throw new TypeError(`The key \`${keyId}\` does not exist.`)
const metadata = Object.assign({}, currentKey.metadata, opts.metadata)
const key = Object.assign(currentKey, pick(opts, KEY_FIELDS), { updatedAt: Date.now() })
const key = Object.assign(currentKey, pick(opts, KEY_FIELDS), {
updatedAt: Date.now()
})
if (Object.keys(metadata).length) key.metadata = metadata
if (key.plan) await plans.retrieve(key.plan, { throwError: true })
await redis.set(keyId, serialize(key))
return key
}
Expand All @@ -83,11 +108,11 @@ export default ({ serialize, deserialize, redis } = {}) => {
const list = async () => {
const keyIds = await redis.keys(`${KEY_PREFIX}*`)
return Promise.all(
keyIds.map(keyIds => retrieve(keyIds, { verify: false }))
keyIds.map(keyIds => retrieve(keyIds, { validate: false }))
)
}

const key = validateKey({ prefix: KEY_PREFIX })
const getKey = validateKey({ prefix: KEY_PREFIX })

return {
create,
Expand Down
Loading