Skip to content

Commit

Permalink
feat(graphql): add data validation to insert and update queries
Browse files Browse the repository at this point in the history
- Add Utils.validator to common package
- Add data validation to insert and update graphql queries
- Add automated tests for data validation
- Add automated tests for insert and update query validations
  • Loading branch information
Frantz Kati committed Nov 27, 2020
1 parent 952d48c commit cc4d7db
Show file tree
Hide file tree
Showing 15 changed files with 560 additions and 13 deletions.
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ export { resource, Resource } from './resources/Resource'
export { dashboard, Dashboard } from './dashboard/Dashboard'
export { valueMetric, ValueMetrics } from './metrics/Value'

export { Utils } from './utils'
export { route, Route } from './api/Route'
export { graphQlQuery, GraphQlQuery } from './api/GraphQlQuery'
4 changes: 4 additions & 0 deletions packages/common/src/resources/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ export class Resource<ResourceType = {}> implements ResourceContract {
)
}

public getPrimaryField() {
return this.data.fields.find(f => f.property.primary)
}

public getUpdateApiExposedFields() {
return this.data.fields.filter(
f => !f.showHideFieldFromApi.hideFromUpdateApi
Expand Down
145 changes: 145 additions & 0 deletions packages/common/src/utils/Validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import * as validator from 'indicative/validator'
import { EntityManager, ReferenceType } from '@mikro-orm/core'
import { DataPayload, ResourceContract } from '@tensei/common'

export class Validator {
constructor(
private resource: ResourceContract,
private manager: EntityManager,
private resourcesMap: { [key: string]: ResourceContract },
private modelId?: string | number
) {
let self = this

validator.extend('unique', {
async: true,
async validate(data, field, args) {
const whereOptions: any = {
[args[0]]: data.original[args[0]]
}

if (args[1] && self.modelId) {
whereOptions.id = {
$ne: self.modelId
}
}

const count = await self.manager.count(
self.resource.data.pascalCaseName,
whereOptions
)

return count === 0
}
})
}

getValidationRules = (creationRules = true) => {
const fields = this.resource.data.fields.filter(field =>
creationRules
? field.showHideField.showOnCreation
: field.showHideField.showOnUpdate
)

const rules: {
[key: string]: string
} = {}

fields.forEach(field => {
const fieldValidationRules = Array.from(
new Set([
...field.validationRules,
...field[
creationRules
? 'creationValidationRules'
: 'updateValidationRules'
]
])
).join('|')

if (field.relatedProperty.reference) {
const relatedResource = this.resourcesMap[
field.relatedProperty.type!
]

const primaryFieldType =
relatedResource.getPrimaryField()!.property.type ===
'number'
? 'number'
: 'string'

if (
[
ReferenceType.MANY_TO_MANY,
ReferenceType.ONE_TO_MANY
].includes(field.relatedProperty.reference!)
) {
rules[field.databaseField] = 'array'
rules[`${field.databaseField}.*`] = primaryFieldType
} else {
rules[field.databaseField] = primaryFieldType
}
}

if (fieldValidationRules) {
rules[field.databaseField] = fieldValidationRules
}
})

return rules
}

getResourceFieldsFromPayload = (payload: DataPayload) => {
let validPayload: DataPayload = {}

this.resource.data.fields.forEach(field => {
if (Object.keys(payload).includes(field.databaseField)) {
validPayload[field.databaseField] = payload[field.databaseField]
}
})

return validPayload
}

breakFieldsIntoRelationshipsAndNonRelationships = (
payload: DataPayload
) => {
const relationshipFieldsPayload: DataPayload = {}
const nonRelationshipFieldsPayload: DataPayload = {}

this.resource.data.fields.forEach(field => {
if (Object.keys(payload).includes(field.databaseField)) {
if (field.relatedProperty.reference) {
relationshipFieldsPayload[field.databaseField] =
payload[field.databaseField]
} else {
nonRelationshipFieldsPayload[field.databaseField] =
payload[field.databaseField]
}
}
})

return {
relationshipFieldsPayload,
nonRelationshipFieldsPayload
}
}

validate = async (
payload: DataPayload,
creationRules: boolean = true,
modelId?: string | number
): Promise<DataPayload> => {
try {
const parsedPayload: DataPayload = await validator.validateAll(
this.getResourceFieldsFromPayload(payload),
this.getValidationRules(creationRules),
this.resource.data.validationMessages
)

return [true, parsedPayload]
} catch (errors) {
return [false, errors]
}
}
}
14 changes: 14 additions & 0 deletions packages/common/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Validator } from './Validator'
import { EntityManager } from '@mikro-orm/core'
import { ResourceContract } from '@tensei/core'

export const Utils = {
validator: (
resource: ResourceContract,
manager: EntityManager,
resourcesMap: {
[key: string]: ResourceContract
},
modelId?: string | number
) => new Validator(resource, manager, resourcesMap, modelId)
}
25 changes: 24 additions & 1 deletion packages/common/typings/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ declare module '@tensei/common/config' {
handle(handler: RouteConfig['handler']): this
}

interface UtilsContract {
validator: (
resource: ResourceContract,
manager: EntityManager,
resourcesMap: {
[key: string]: ResourceContract
},
modelId?: string | number | undefined
) => {
getValidationRules: (
creationRules?: boolean
) => {
[key: string]: string
}
validate: (
payload: DataPayload,
creationRules?: boolean,
modelId?: string | number | undefined
) => Promise<[boolean, DataPayload | array[any]]>
}
}

interface GraphQlQueryContract {
config: GraphQlQueryConfig
path(path: string): this
Expand Down Expand Up @@ -112,7 +134,7 @@ declare module '@tensei/common/config' {
authenticationError: (message?: string) => unknown
forbiddenError: (message?: string) => unknown
validationError: (message?: string) => unknown
userInputError: (message?: string) => unknown
userInputError: (message?: string, properties?: any) => unknown
}

interface ApiContext extends GraphQLPluginContext {}
Expand Down Expand Up @@ -476,4 +498,5 @@ declare module '@tensei/common/config' {
}
const graphQlQuery: (name?: string) => GraphQlQueryContract
const route: (name?: string) => RouteContract
const Utils: UtilsContract
}
1 change: 1 addition & 0 deletions packages/common/typings/resources.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ declare module '@tensei/common/resources' {
hideFromDeleteApi(): this
hideFromFetchApi(): this
hideFromShowApi(): this
getPrimaryField(): FieldContract | undefined
getCreateApiExposedFields(): FieldContract[]
getUpdateApiExposedFields(): FieldContract[]
getFetchApiExposedFields(): FieldContract[]
Expand Down
42 changes: 40 additions & 2 deletions packages/graphql/src/Resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Utils } from '@tensei/common'
import { parseResolveInfo } from 'graphql-parse-resolve-info'
import { EntityManager, ReferenceType } from '@mikro-orm/core'
import {
Expand Down Expand Up @@ -390,9 +391,21 @@ export const getResolvers = (
.internal()
.resource(resource)
.handle(async (_, args, ctx, info) => {
const [passed, payload] = await Utils.validator(
resource,
ctx.manager,
ctx.resourcesMap
).validate(args.object)

if (!passed) {
throw ctx.userInputError('Validation failed.', {
errors: payload
})
}

const data = ctx.manager.create(
resource.data.pascalCaseName,
args.object
payload
)

await ctx.manager.persistAndFlush(data)
Expand Down Expand Up @@ -463,7 +476,20 @@ export const getResolvers = (
.getRepository<any>(resource.data.pascalCaseName)
.findOneOrFail(args.id)

ctx.manager.assign(data, args.object)
const [passed, payload] = await Utils.validator(
resource,
ctx.manager,
ctx.resourcesMap,
args.id
).validate(args.object)

if (!passed) {
throw ctx.userInputError('Validation failed.', {
errors: payload
})
}

ctx.manager.assign(data, payload)

await ctx.manager.persistAndFlush(data)

Expand Down Expand Up @@ -498,6 +524,18 @@ export const getResolvers = (
parseWhereArgumentsToWhereQuery(args.where)
)

const [passed, payload] = await Utils.validator(
resource,
ctx.manager,
ctx.resourcesMap
).validate(args.object)

if (!passed) {
throw ctx.userInputError('Validation failed.', {
errors: payload
})
}

data.forEach(d => ctx.manager.assign(d, args.object))

await ctx.manager.persistAndFlush(data)
Expand Down
10 changes: 8 additions & 2 deletions packages/graphql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,8 +626,14 @@ input id_where_query {
context.validationError = (message?: string) =>
new ValidationError(message || 'Validation failed.')

context.userInputError = (message?: string) =>
new UserInputError(message || 'Invalid user input.')
context.userInputError = (
message?: string,
properties?: any
) =>
new UserInputError(
message || 'Invalid user input.',
properties
)

const result = await resolve(
parent,
Expand Down
1 change: 1 addition & 0 deletions packages/tests/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ let loggedDatabase = false
export const fakeTag = () =>
({
name: Faker.lorem.word(),
priority: 3,
description: Faker.lorem.sentence()
} as {
id?: string | number
Expand Down
9 changes: 6 additions & 3 deletions packages/tests/helpers/resources/Post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
resource,
textarea,
belongsTo,
belongsToMany
belongsToMany,
hasMany
} from '@tensei/common'

export default resource('Post')
Expand Down Expand Up @@ -65,7 +66,8 @@ export default resource('Post')
.sortable()
.searchable()
.unique()
.rules('required', 'max:64'),
.creationRules('required', 'max:64', 'unique:title')
.updateRules('required', 'max:64', 'unique:title,{id}'),
text('Description').rules('required'),
textarea('Content')
.rules('required', 'max:2000', 'min:12')
Expand Down Expand Up @@ -104,7 +106,8 @@ export default resource('Post')
dateTime('Scheduled For')
.rules('required', 'date')
.format('do MMM yyyy, hh:mm a'),
belongsToMany('Tag')
belongsToMany('Tag'),
hasMany('Comment')
])
.perPageOptions([25, 50, 100])
.displayField('title')
6 changes: 5 additions & 1 deletion packages/tests/helpers/resources/Tag.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { text, resource, textarea, belongsToMany } from '@tensei/common'
import { text, resource, textarea, belongsToMany, number } from '@tensei/common'

export default resource('Tag')
.fields([
text('Name')
.rules('required')
.searchable(),
number('Priority')
.nullable()
.default(1)
.rules('integer', 'under:5'),
textarea('Description'),
belongsToMany('Post')
])
Expand Down
1 change: 1 addition & 0 deletions packages/tests/packages/common/setup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../../helpers'
Loading

0 comments on commit cc4d7db

Please sign in to comment.