duck-api
Installation
$ npm i duck-api --save
# or
$ yarn add duck-api
Features
- connects socket.io
- proxies emitted events via socket.io
- loads api plugins
- provides information about available endpoints / schemas / entities
- filters endpoints access
- Provides a way of querying multiple endpoints at a time
- Restricts access via get variables
- Restricts post / patch / delete body
- Hooks ApiEndpoint into a koa router
- converts a crud endpoint in an open api route
- Converts an entity into an array of crud endpoints
- converts client into a crud-endpoint
- translates directory into routes
- Load entities from directory
- Signs JWT sessions
- Validates provided token via head setting $pleasure.user when valid
- Validates provided token via cookie setting $pleasure.user when valid
- Rejects token when expired
- Filters response data
- converts route tree into crud endpoint
- Parses query objects
connects socket.io
return new Promise((resolve, reject) => {
const socket = io('http://localhost:3000')
socket.on('connect', () => {
t.pass()
resolve()
})
socket.on('error', reject)
setTimeout(reject, 3000)
})
proxies emitted events via socket.io
const socket = io('http://localhost:3000')
await new Promise((r) => socket.on('connect', r))
const eventReceived = []
socket.on('method', p => {
eventReceived.push(p)
})
const { data: { data } } = await axios.post('http://localhost:3000/racks/test', {
name: 'Martin',
email: 'tin@devtin.io',
})
t.truthy(data)
const { data: { data: data2 } } = await axios.post(`http://localhost:3000/racks/test/${ data._id }/query`, {
name: 'Martin',
email: 'tin@devtin.io',
})
t.true(Array.isArray(data2))
t.snapshot(data2)
t.true(eventReceived.length > 0)
loads api plugins
const { data: response } = await axios.get('http://localhost:3000/some-plugin')
t.is(response, 'some plugin here!')
provides information about available endpoints / schemas / entities
const { data: response } = await axios.get('http://localhost:3000/racks')
t.true(typeof response.data === 'object')
t.true(Object.hasOwnProperty.call(response.data, 'test'))
filters endpoints access
const { data: response1 } = await axios.get('http://localhost:3000/racks/test/clean')
t.deepEqual(response1.data, {})
const { data: response2 } = await axios.get('http://localhost:3000/racks/test/clean?level=1')
t.deepEqual(response2.data, { name: 'Martin', email: 'tin@devtin.io' })
const { data: response3 } = await axios.get('http://localhost:3000/racks/test/clean?level=2')
t.deepEqual(response3.data, { name: 'Martin' })
Provides a way of querying multiple endpoints at a time
Restricts access via get variables
const Api = apiSchemaValidationMiddleware({
// passes the schema
get: {
quantity: {
type: Number,
required: false
}
}
})
const none = Object.assign({}, ctxStub, requestCtx.none)
await Api(none, fnStub)
t.truthy(none.$pleasure.get)
t.is(Object.keys(none.$pleasure.get).length, 0)
const quantity = Object.assign({}, ctxStub, requestCtx.quantity)
await Api(quantity, fnStub)
t.truthy(quantity.$pleasure.get)
t.is(quantity.$pleasure.get.quantity, 3)
const wrongQuantity = Object.assign({}, ctxStub, requestCtx.wrongQuantity)
const error = await t.throwsAsync(() => Api(wrongQuantity, fnStub))
t.is(error.message, 'Data is not valid')
t.is(error.errors.length, 1)
t.is(error.errors[0].message, 'Invalid number')
t.is(error.errors[0].field.fullPath, 'quantity')
Restricts post / patch / delete body
const Api = apiSchemaValidationMiddleware({
body: {
name: {
type: String,
required: [true, `Please enter your full name`]
},
birthday: {
type: Date
}
}
})
const fullContact = Object.assign({}, ctxStub, requestCtx.fullContact)
const wrongContact = Object.assign({}, ctxStub, requestCtx.wrongContact)
await t.notThrowsAsync(() => Api(fullContact, fnStub))
t.is(fullContact.$pleasure.body.name, 'Martin Rafael Gonzalez')
t.true(fullContact.$pleasure.body.birthday instanceof Date)
const error = await t.throwsAsync(() => Api(wrongContact, fnStub))
t.is(error.message, 'Data is not valid')
t.is(error.errors.length, 1)
t.is(error.errors[0].message, `Invalid date`)
t.is(error.errors[0].field.fullPath, `birthday`)
Hooks ApiEndpoint into a koa router
crudEndpointIntoRouter(koaRouterMock, {
create: { handler () { } },
read: { handler () { } },
update: { handler () { } },
delete: { handler () { } }
})
t.true(koaRouterMock.post.calledOnce)
t.true(koaRouterMock.get.calledOnce)
t.true(koaRouterMock.patch.calledOnce)
t.true(koaRouterMock.delete.calledOnce)
converts a crud endpoint in an open api route
const swaggerEndpoint = crudEndpointToOpenApi(crudEndpoint)
t.truthy(swaggerEndpoint)
t.snapshot(swaggerEndpoint)
Converts an entity into an array of crud endpoints
const converted = await duckRackToCrudEndpoints(anEntity, entityDriver)
t.true(Array.isArray(converted))
t.is(converted.length, 3)
converted.forEach(entity => {
t.notThrows(() => CRUDEndpoint.parse(entity))
})
t.is(converted[0].path, '/papo')
t.truthy(converted[0].create)
t.truthy(converted[0].read)
t.truthy(converted[0].update)
t.truthy(converted[0].delete)
t.snapshot(converted)
converts client into a crud-endpoint
const client = await Client.parse({
name: 'PayPal',
methods: {
issueTransaction: {
description: 'Issues a transaction',
input: {
name: String
},
handler ({ name }) {
return name
}
},
issueRefund: {
description: 'Issues a refund',
input: {
transactionId: Number
},
handler ({transactionId}) {
return transactionId
}
}
}
})
const crudEndpoints = gatewayToCrudEndpoints(client)
await Promise.each(crudEndpoints, async crudEndpoint => {
t.true(await CRUDEndpoint.isValid(crudEndpoint))
})
translates directory into routes
const routes = await loadApiCrudDir(path.join(__dirname, './fixtures/app-test/api'))
t.is(routes.length, 5)
Load entities from directory
const entities = await loadEntitiesFromDir(path.join(__dirname, './fixtures/app-test/entities'))
t.is(entities.length, 2)
t.truthy(typeof entities[0].duckModel.clean)
Signs JWT sessions
const res = await axios.post('http://0.0.0.0:3000/sign-in', {
fullName: 'pablo marmol'
})
t.is(res.headers['set-cookie'].filter((line) => {
return /^accessToken=/.test(line)
}).length, 1)
t.truthy(res.data.accessToken)
t.truthy(res.data.refreshToken)
Validates provided token via head setting $pleasure.user when valid
const userData = { name: 'pedro picapiedra' }
const user = (await axios.get('http://0.0.0.0:3000/user', {
headers: {
authorization: `Bearer ${sign(userData, privateKey, { expiresIn: '1m' })}`
}
})).data
t.like(user, userData)
Validates provided token via cookie setting $pleasure.user when valid
const userData = { name: 'pedro picapiedra' }
const user = (await axios.get('http://0.0.0.0:3000/user', {
headers: {
Cookie: `accessToken=${sign(userData, privateKey, { expiresIn: '1m' })};`
}
})).data
t.like(user, userData)
Rejects token when expired
const userData = { name: 'pedro picapiedra' }
const error = (await axios.get('http://0.0.0.0:3000/user', {
headers: {
Cookie: `accessToken=${sign(userData, privateKey, { expiresIn: '0s' })};`
}
})).data
t.like(error, {
code: 500,
error: {
message: 'Invalid token'
}
})
Filters response data
const next = (ctx) => () => {
Object.assign(ctx, { body: Body })
}
const Body = {
firstName: 'Martin',
lastName: 'Gonzalez',
address: {
street: '2451 Brickell Ave',
zip: 33129
}
}
const ctx = (level = 'nobody', body = Body) => {
return {
body: {},
$pleasure: {
state: {}
},
user: {
level
}
}
}
const middleware = responseAccessMiddleware(await EndpointHandler.schemaAtPath('access').parse(ctx => {
if (ctx.user.level === 'nobody') {
return false
}
if (ctx.user.level === 'admin') {
return true
}
return ['firstName', 'lastName', 'address.zip']
}))
const nobodyCtx = ctx('nobody')
await middleware(nobodyCtx, next(nobodyCtx))
t.deepEqual(nobodyCtx.body, {})
const userCtx = ctx('user')
await middleware(userCtx, next(userCtx))
t.deepEqual(userCtx.body, {
firstName: 'Martin',
lastName: 'Gonzalez',
address: {
zip: 33129
}
})
const adminCtx = ctx('admin')
await middleware(adminCtx, next(adminCtx))
t.deepEqual(adminCtx.body, Body)
converts route tree into crud endpoint
const routeTree = {
somePath: {
to: {
someMethod: {
read: {
description: 'Some method description',
handler () {
},
get: {
name: String
}
}
}
}
},
and: {
otherMethod: {
create: {
description: 'create one',
handler () {
}
},
read: {
description: 'read one',
handler () {
}
}
},
anotherMethod: {
create: {
description: 'another one (another one)',
handler () {
}
}
}
}
}
const endpoints = await routeToCrudEndpoints(routeTree)
t.truthy(endpoints)
t.snapshot(endpoints)
Parses query objects
const parsed = await Query.parse({
address: {
zip: {
$gt: 34
}
}
})
t.deepEqual(parsed, {
address: {
zip: {
$gt: 34
}
}
})
apiSchemaValidationMiddleware([get], [body]) ⇒
Throws:
-
Schema~ValidationError
if any validation fails
Param | Type | Default | Description |
---|---|---|---|
[get] |
Schema , Object , Boolean
|
true |
Get (querystring) schema. true for all; false for none; schema for validation |
[body] |
Schema , Object , Boolean
|
true |
Post / Delete / Patch (body) schema. true for all; false for none; schema for validation |
Returns: Function - Koa middleware
Description:
Validates incoming traffic against given schemas
function
responseAccessMiddleware(access, thisContext) ⇒ Param | Type | Description |
---|---|---|
access | function |
callback function receives ctx |
thisContext | object |
callback function receives ctx |
crudEndpointIntoRouter(router, crudEndpoint)
Param |
---|
router |
crudEndpoint |
Description:
Takes given crudEndpoint as defined
Array.<CRUDEndpoint>
loadApiCrudDir(dir) ⇒ Param | Type | Description |
---|---|---|
dir | String |
The directory to look for files |
Description:
Look for JavaScript files in given directory
loadEntitiesFromDir(dir)
Param |
---|
dir |
Description:
Reads given directory looking for *.js files and parses them into
duckRackToCrudEndpoints(entity, duckRack) ⇒
Param | Type |
---|---|
entity | |
duckRack | Object |
Returns: Promise<[]|*>
function
loadPlugin(pluginName, [baseDir]) ⇒ Param | Type | Description |
---|---|---|
pluginName |
String , Array , function
|
|
[baseDir] | String |
Path to the plugins dir. Defaults to project's local. |
Description:
Resolves given plugin by trying to globally resolve it, otherwise looking in the plugins.dir
directory or
resolving the giving absolute path. If the given pluginName is a function, it will be returned with no further logic.
function
jwtAccess(options) ⇒ Param | Type | Default | Description |
---|---|---|---|
options | Object |
||
options.privateKey | String |
||
[options.headerName] | String |
authorization |
|
[options.cookieName] | String |
accessToken |
|
[options.algorithm] | String |
HS256 |
see https://www.npmjs.com/package/jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback |
[options.expiresIn] |
Number , String
|
15 * 60 |
Object
EndpointHandler :
Object
CRUD : Description:
An object representing all CRUD operations including listing and optional hook for any request.
Object
CRUDEndpoint : Extends: CRUD
Description:
A CRUD representation of an endpoint
Object
Entity :
function
ApiPlugin : Param | Description |
---|---|
app | The koa app |
server | The http server |
io | The socket.io instance |
router | Main koa router |
License
© 2020-present Martin Rafael Gonzalez tin@devtin.io