Skip to content

Commit

Permalink
feat: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
anonrig committed Jul 24, 2021
0 parents commit c51f6eb
Show file tree
Hide file tree
Showing 11 changed files with 7,601 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
coverage
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.test.js
fixtures
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Ajv validation for Mali.js gRPC microservices

This package adds **opinionated** [Ajv](https://ajv.js.org) input validation support for [Mali.js](https://mali.js.org) gRPC microservices.

## Install

```bash
npm i --save mali-ajv
```

## Setup

```javascript
import Mali from 'mali'
import { addSchemas } from './grpc.validator.js'
import * as accounts from './endpoints/accounts.schema.js'
import * as memberships from './endpoints/memberships.schema.js'

const app = new Mali()
app.addService(file, 'Memberships', {})
app.addService(file, 'Accounts', {})
app.use(addSchemas(app, { accounts, memberships }))
```

## Usage

Example request:

```json
{
"identity_id": "45670d4a-1185-4e5a-bd3",
"name": ""
}
```

Throws the following error:

```json
{
"error": "9 FAILED_PRECONDITION: data.identity_id should match format \"uuid\""
}
```
7 changes: 7 additions & 0 deletions fixtures/accounts.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const findOne = {
type: 'object',
properties: {
account_id: { type: 'string', format: 'uuid' },
},
required: ['account_id'],
}
7 changes: 7 additions & 0 deletions fixtures/organizations.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const findOne = {
type: 'object',
properties: {
organization_id: { type: 'string', format: 'uuid' },
},
required: ['organization_id'],
}
34 changes: 34 additions & 0 deletions fixtures/test-service.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
syntax = "proto3";

service Accounts {
rpc findOne(AccountFindOne) returns (Account) {}
rpc findAll(Empty) returns (AccountsResponse) {}
}

service Organizations {
rpc findOne(OrganizationFindOne) returns (Organization) {}
}

message Empty {}

message AccountFindOne {
string account_id = 1;
}

message OrganizationFindOne {
string organization_id = 1;
}

message Account {
string account_id = 1;
string name = 2;
}

message Organization {
string organization_id = 1;
string name = 2;
}

message AccountsResponse {
repeated Account rows = 1;
}
46 changes: 46 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import grpc from '@grpc/grpc-js'
import Ajv from 'ajv'
import uuid from '@socketkit/ajv-uuid'

const ajv = new Ajv({ removeAdditional: true })
uuid(ajv)
export default ajv

/**
* @function addSchemas
* @description Adds JSON schemas to Mali instance
* @param {import('mali').Mali} mali Mali Instance
* @param {object} schemas Schema function declerations
* @returns {Function} callback function
*/
export function addSchemas(app, schemas = {}) {
app.context.schemas = new Map(Object.entries(schemas))

return async function (context, next) {
const { req } = context.request
const name = context.service.toLowerCase()
const service_schemas = app.context.schemas.get(name)

// Schema does not include the given service decleration
if (!service_schemas) {
return next()
}

const schema = service_schemas[context.name]

// Service schema does not include the given function
if (!schema) {
return next()
}

const isValid = await ajv.validate(schema, req)

if (!isValid) {
const error = new Error(ajv.errorsText(ajv.errors))
error.code = grpc.status.FAILED_PRECONDITION
throw error
}

return next()
}
}
145 changes: 145 additions & 0 deletions index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { randomUUID } from 'crypto'
import path from 'path'
import test from 'ava'
import Mali from 'mali'
import grpc from '@grpc/grpc-js'
import loader from '@grpc/proto-loader'
import { promisify } from 'util'

import * as account_schemas from './fixtures/accounts.schema.js'
import * as organization_schemas from './fixtures/organizations.schema.js'

import { addSchemas } from './index.js'

const options = {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
}

const file = path.join(path.resolve('.'), 'fixtures/test-service.proto')

const getRandomPort = (a = 1000, b = 65000) => {
const lower = Math.ceil(Math.min(a, b))
const upper = Math.floor(Math.max(a, b))
return Math.floor(lower + Math.random() * (upper - lower + 1))
}

function promisifyAll(subscriber) {
const to = {}
for (const k in subscriber) {
if (typeof subscriber[k] != 'function') continue
to[k] = promisify(subscriber[k].bind(subscriber))
}
return to
}

const getClients = (port) => {
const { Accounts, Organizations } = grpc.loadPackageDefinition(
loader.loadSync(path.join('.', 'fixtures/test-service.proto'), options),
)

return {
accounts: new Accounts(`0.0.0.0:${port}`, grpc.credentials.createInsecure()),
organizations: new Organizations(`0.0.0.0:${port}`, grpc.credentials.createInsecure()),
}
}

test('should throw error on invalid request', async (t) => {
const port = getRandomPort()
const app = new Mali()
app.addService(file, 'Accounts', options)
app.addService(file, 'Organizations', options)
app.use(addSchemas(app, { accounts: account_schemas }))
app.use('Accounts', 'findOne', (ctx) => {
ctx.res = { account_id: randomUUID(), name: 'hello' }
})
app.use('Organizations', 'findOne', (ctx) => {
ctx.res = { organization_id: randomUUID(), name: 'hello' }
})
await app.start(`0.0.0.0:${port}`)
t.teardown(() => app.close())

const clients = getClients(port)
const accounts = promisifyAll(clients.accounts)
try {
await accounts.findOne({ account_id: 'hello' })
throw new Error(`Invalid`)
} catch (error) {
t.not(error.message, 'Invalid')
t.is(error.code, grpc.status.FAILED_PRECONDITION)
t.is(error.details, `data/account_id must match format "uuid"`)
}
})

test('should not throw error on valid data', async (t) => {
const port = getRandomPort()
const app = new Mali()
app.addService(file, 'Accounts', options)
app.addService(file, 'Organizations', options)
app.use(addSchemas(app, { accounts: account_schemas }))
app.use('Accounts', 'findOne', (ctx) => {
ctx.res = { account_id: randomUUID(), name: 'hello' }
})
app.use('Organizations', 'findOne', (ctx) => {
ctx.res = { organization_id: randomUUID(), name: 'hello' }
})
await app.start(`0.0.0.0:${port}`)
t.teardown(() => app.close())

const clients = getClients(port)
const accounts = promisifyAll(clients.accounts)
const response = await accounts.findOne({ account_id: randomUUID() })
t.truthy(response)
})

test('should not throw error on missing schema', async (t) => {
const port = getRandomPort()
const app = new Mali()
app.addService(file, 'Accounts', options)
app.addService(file, 'Organizations', options)
app.use(addSchemas(app, { accounts: account_schemas }))
app.use('Accounts', 'findOne', (ctx) => {
ctx.res = { account_id: randomUUID(), name: 'hello' }
})
app.use('Organizations', 'findOne', (ctx) => {
ctx.res = { organization_id: randomUUID(), name: 'hello' }
})
await app.start(`0.0.0.0:${port}`)
t.teardown(() => app.close())

const clients = getClients(port)
const organizations = promisifyAll(clients.organizations)
const response = await organizations.findOne({ organization_id: randomUUID() })
t.truthy(response)
})

test('should not throw error on missing function in a schema', async (t) => {
const port = getRandomPort()
const app = new Mali()
app.addService(file, 'Accounts', options)
app.addService(file, 'Organizations', options)
app.use(addSchemas(app, { accounts: account_schemas }))
app.use({
Accounts: {
findOne: (ctx) => {
ctx.res = { account_id: randomUUID(), name: 'hello' }
},
findAll: (ctx) => {
ctx.res = { rows: [] }
}
}
})
app.use('Organizations', 'findOne', (ctx) => {
ctx.res = { organization_id: randomUUID(), name: 'hello' }
})
await app.start(`0.0.0.0:${port}`)
t.teardown(() => app.close())

const clients = getClients(port)
const accounts = promisifyAll(clients.accounts)
const response = await accounts.findAll({})
t.truthy(response.rows)
})
12 changes: 12 additions & 0 deletions jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"lib": ["ES2020"],
"module": "ES2020",
"moduleResolution": "node",
"target": "ES2020",
"checkJs": true
},
"exclude": ["node_modules"],
"include": ["./*.js"]
}
Loading

0 comments on commit c51f6eb

Please sign in to comment.