Skip to content

Commit

Permalink
Makes validateWith async and a new validateWithSync synchronous (#7681)
Browse files Browse the repository at this point in the history
* Adds new validateWithSync function

* add codemod

* Adds tests

* Adds docs for validateWith()

* fix yarn check

---------

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>
  • Loading branch information
cannikin and jtoar authored Mar 23, 2023
1 parent 039cd29 commit 9bdf3b0
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 13 deletions.
31 changes: 24 additions & 7 deletions docs/docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,28 @@ Finally, Services can also be called from [serverless functions](serverless-func

## Service Validations

Starting with `v0.38`, Redwood includes a feature we call Service Validations. These simplify an extremely common task: making sure that incoming data is formatted properly before continuing. These validations are meant to be included at the start of your Service function and will throw an error if conditions are not met:
Redwood includes a feature we call Service Validations. These simplify an extremely common task: making sure that incoming data is formatted properly before continuing. These validations are meant to be included at the start of your Service function and will throw an error if conditions are not met:

```jsx
import { validate, validateWith, validateUniqueness } from '@redwoodjs/api'
import { validate, validateWith, validateWithSync, validateUniqueness } from '@redwoodjs/api'

export const createUser = async ({ input }) => {
validate(input.firstName, 'First name', {
presence: true,
exclusion: { in: ['Admin', 'Owner'], message: 'That name is reserved, sorry!' },
length: { min: 2, max: 255 }
})
validateWith(() => {
validateWithSync(() => {
if (input.role === 'Manager' && !context.currentUser.roles.includes('admin')) {
throw 'Only Admins can create new Managers'
}
})
validateWith(async () => {
const inviteCount = await db.invites.count({ where: { userId: currentUser.id } })
if (inviteCount >= 10) {
throw 'You have already invited your max of 10 users'
}
})

return validateUniqueness('user', { username: input.username }, (db) => {
return db.user.create({ data: input })
Expand Down Expand Up @@ -610,19 +616,18 @@ validate(input.value, 'Value', {
}
})
```
### validateWith()
### validateWithSync()
`validateWith()` is simply given a function to execute. This function should throw with a message if there is a problem, otherwise do nothing.
```jsx
validateWith(() => {
validateWithSync(() => {
if (input.name === 'Name') {
throw "You'll have to be more creative than that"
}
})

validateWith(() => {
validateWithSync(() => {
if (input.name === 'Name') {
throw new Error("You'll have to be more creative than that")
}
Expand All @@ -633,6 +638,18 @@ Either of these errors will be caught and re-thrown as a `ServiceValidationError
You could just write your own function and throw whatever you like, without using `validateWith()`. But, when accessing your Service function through GraphQL, that error would be swallowed and the user would simply see "Something went wrong" for security reasons: error messages could reveal source code or other sensitive information so most are hidden. Errors thrown by Service Validations are considered "safe" and allowed to be shown to the client.
### validateWithSync()
The same behavior as `validateWithSync()` but works with Promises.
```jsx
validateWithSync(async () => {
if (await db.products.count() >= 100) {
throw "There can only be a maximum of 100 products in your store"
}
})
```
### validateUniqueness()
This validation guarantees that the field(s) given in the first argument are unique in the database before executing the callback given in the last argument. If a record is found with the given fields then an error is thrown and the callback is not invoked.
Expand Down
46 changes: 41 additions & 5 deletions packages/api/src/validations/__tests__/validations.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as ValidationErrors from '../errors'
import { validate, validateUniqueness, validateWith } from '../validations'
import {
validate,
validateUniqueness,
validateWith,
validateWithSync,
} from '../validations'

describe('validate absence', () => {
it('checks if value is null or undefined', () => {
Expand Down Expand Up @@ -1146,18 +1151,18 @@ describe('validate', () => {
})
})

describe('validateWith', () => {
describe('validateWithSync', () => {
it('runs a custom function as a validation', () => {
const validateFunction = jest.fn()
validateWith(validateFunction)
validateWithSync(validateFunction)

expect(validateFunction).toBeCalledWith()
})

it('catches errors and raises ServiceValidationError', () => {
// Error instance
try {
validateWith(() => {
validateWithSync(() => {
throw new Error('Invalid value')
})
} catch (e) {
Expand All @@ -1167,7 +1172,7 @@ describe('validateWith', () => {

// Error string
try {
validateWith(() => {
validateWithSync(() => {
throw 'Bad input'
})
} catch (e) {
Expand All @@ -1179,6 +1184,37 @@ describe('validateWith', () => {
})
})

describe('validateWith', () => {
it('runs a custom function as a validation', () => {
const validateFunction = jest.fn()
validateWith(validateFunction)

expect(validateFunction).toBeCalledWith()
})

it('catches errors and raises ServiceValidationError', async () => {
// Error instance
try {
await validateWith(() => {
throw new Error('Invalid value')
})
} catch (e) {
expect(e instanceof ValidationErrors.ServiceValidationError).toEqual(true)
expect(e.message).toEqual('Invalid value')
}
// Error string
try {
await validateWith(() => {
throw 'Bad input'
})
} catch (e) {
expect(e instanceof ValidationErrors.ServiceValidationError).toEqual(true)
expect(e.message).toEqual('Bad input')
}
expect.assertions(4)
})
})

// Mock just enough of PrismaClient that we can test a transaction is running.
// Prisma.PrismaClient is a class so we need to return a function that returns
// the actual methods of an instance of the class
Expand Down
12 changes: 11 additions & 1 deletion packages/api/src/validations/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ export function validate(
// just send "Something went wrong" back to the client. This captures any custom
// error you throw and turns it into a ServiceValidationError which will show
// the actual error message.
export const validateWith = (func: () => void) => {
export const validateWithSync = (func: () => void) => {
try {
func()
} catch (e) {
Expand All @@ -623,6 +623,16 @@ export const validateWith = (func: () => void) => {
}
}

// Async version is the default
export const validateWith = async (func: () => Promise<any>) => {
try {
await func()
} catch (e) {
const message = (e as Error).message || (e as string)
throw new ValidationErrors.ServiceValidationError(message)
}
}

// Wraps `callback` in a transaction to guarantee that `field` is not found in
// the database and that the `callback` is executed before someone else gets a
// chance to create the same value.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Rename `validateWith`

For (https://github.com/redwoodjs/redwood/pull/7681)[https://github.com/redwoodjs/redwood/pull/7681].
This codemod renames any instance of `validateWith` to `validateWithSync`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
validateWith(() => {
if (input.name === 'Name') {
throw "You'll have to be more creative than that"
}
})

validateWith(() => {
if (input.name === 'Name') {
throw new Error("You'll have to be more creative than that")
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
validateWithSync(() => {
if (input.name === 'Name') {
throw "You'll have to be more creative than that"
}
})

validateWithSync(() => {
if (input.name === 'Name') {
throw new Error("You'll have to be more creative than that")
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
describe('renameValidateWith', () => {
it('Converts validateWith to validateWithSync', async () => {
await matchTransformSnapshot('renameValidateWith', 'default')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { FileInfo, API } from 'jscodeshift'

export default function transform(file: FileInfo, api: API) {
const j = api.jscodeshift
const root = j(file.source)

root
.find(j.Identifier, {
type: 'Identifier',
name: 'validateWith',
})
.replaceWith({ type: 'Identifier', name: 'validateWithSync' })

return root.toSource()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import path from 'path'

import task, { TaskInnerAPI } from 'tasuku'

import getFilesWithPattern from '../../../lib/getFilesWithPattern'
import getRWPaths from '../../../lib/getRWPaths'
import runTransform from '../../../lib/runTransform'

export const command = 'rename-validate-with'
export const description =
'(v4.x.x->v5.x.x) Converts validateWith to validateWithSync'

export const handler = () => {
task('Rename Validate With', async ({ setOutput }: TaskInnerAPI) => {
const rwPaths = getRWPaths()

const files = getFilesWithPattern({
pattern: 'validateWith',
filesToSearch: [rwPaths.api.src],
})

await runTransform({
transformPath: path.join(__dirname, 'renameValidateWith.js'),
targetPaths: files,
})

setOutput('All done! Run `yarn rw lint --fix` to prettify your code')
})
}

0 comments on commit 9bdf3b0

Please sign in to comment.