From 85438a7d26f198225834719b034a9afdf673b1ce Mon Sep 17 00:00:00 2001 From: Dillon Redding Date: Fri, 1 Sep 2023 21:19:42 -0500 Subject: [PATCH] Support for client-side validation Resolves #16 --- CHANGELOG.md | 2 + README.md | 3 +- src/index.ts | 3 +- src/submit.spec.ts | 16 ++++++- src/submit.ts | 72 +++++++++++++++++++++++----- src/validate/index.ts | 3 ++ src/validate/no-op-validator.spec.ts | 16 +++++++ src/validate/no-op-validator.ts | 4 ++ src/validate/validation-error.ts | 10 ++++ src/validate/validation-result.ts | 19 ++++++++ src/validate/validator.ts | 7 +++ 11 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 src/validate/index.ts create mode 100644 src/validate/no-op-validator.spec.ts create mode 100644 src/validate/no-op-validator.ts create mode 100644 src/validate/validation-error.ts create mode 100644 src/validate/validation-result.ts create mode 100644 src/validate/validator.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe36cb8..d5236f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), ### Added +- Support for validating fields on action submission ([#16](https://github.com/siren-js/client/issues/16)) - Default JSON serializer ([#27](https://github.com/siren-js/client/issues/27)) +- Advanced `submit` usage examples the JSDocs - Default serializer is available for extension (see [`submit` docs](https://siren-js.github.io/client/functions/submit.html)) ## [0.8.2] - 2023-08-01 diff --git a/README.md b/README.md index d8a630f..70a2ae1 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ Siren is a very powerful hypermedia format that enables a server and its clients - [x] Parse and validate Siren representations - [x] Follow a `Link` (or any URL) - [x] Submit an `Action` - - [x] Customize `Field` serialization - - [ ] Toggle and customize `Field` validation + - [x] Customizable `Field` validation and serialization - [x] Resolve a `SubEntity` - [ ] Traverse an `Entity` via the [Visitor pattern](https://en.wikipedia.org/wiki/Visitor_pattern) - [ ] Crawl a Siren API diff --git a/src/index.ts b/src/index.ts index fb8927f..e4655d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import 'reflect-metadata'; export * from './follow'; +export * from './href'; export * from './models'; export * from './parse'; export * from './resolve'; export * from './serialize'; export * from './submit'; -export * from './href'; +export * from './validate'; diff --git a/src/submit.spec.ts b/src/submit.spec.ts index eb57f24..91a8d08 100644 --- a/src/submit.spec.ts +++ b/src/submit.spec.ts @@ -78,6 +78,18 @@ describe('submit', () => { expect(scope.isDone()).toBe(true); }); + it('should throw when custom validator returns NegativeValidationResult', async () => { + const action = new Action(); + action.name = 'do-something'; + action.href = url; + action.fields = [nameField]; + const validator = jest.fn(() => new NegativeValidationResult()); + + expect(submit(action, { validator })).rejects.toThrow(ValidationError); + expect(validator).toHaveBeenCalledTimes(1); + expect(validator).toHaveBeenCalledWith(action.fields); + }); + it('should use custom serializer', async () => { const action = new Action(); action.name = 'do-something'; @@ -91,7 +103,7 @@ describe('submit', () => { `; const contentType = 'text/xml'; - const serializer: Serializer = () => Promise.resolve({ content, contentType }); + const serializer = jest.fn(() => Promise.resolve({ content, contentType })); const scope = nock(baseUrl, { reqheaders: { 'Content-Type': contentType } }) .post(path, content) .reply(204); @@ -101,6 +113,8 @@ describe('submit', () => { expect(response.url).toBe(url); expect(response.status).toBe(204); expect(scope.isDone()).toBe(true); + expect(serializer).toHaveBeenCalledTimes(1); + expect(serializer).toHaveBeenCalledWith(action.type, action.fields); }); describe('requestInit option', () => { diff --git a/src/submit.ts b/src/submit.ts index b55227d..6420d73 100644 --- a/src/submit.ts +++ b/src/submit.ts @@ -4,6 +4,8 @@ import { Href } from './href'; import { Action } from './models/action'; import { Serializer } from './serialize'; import { defaultSerializer } from './serialize/default-serializer'; +import { NegativeValidationResult, ValidationError, Validator } from './validate'; +import { noOpValidator } from './validate/no-op-validator'; export interface SubmitOptions { /** @@ -20,21 +22,61 @@ export interface SubmitOptions { * {@linkcode Serializer} used to serialize an {@link Action#fields `Action`'s `fields`} */ serializer?: Serializer; + + /** + * {@linkcode Validator} used to validate an {@link Action#fields `Action`'s `fields`} + */ + validator?: Validator; } /** * Submits the given `action` by making an HTTP request according to `action`'s - * `method` and `href`. If `fields` are present in `action`, they are serialized - * according to `options.serializer` using `action.type` and `action.fields`. By - * default, a serializer supporting the following `type`s is provided: - * - `application/x-www-form-urlencoded` - * - `multipart/form-data` - * - `text/plain` - * If `action.method` is `'GET'` or `'DELETE'`, the serialized content is placed - * in the query string. Otherwise, the content is placed in the request body. - * @param action Siren `Action` to submit - * @param options Submission configuration - * @returns `Promise` that fulfills with an HTTP `Response` object + * `method` and `href`. + * + * If `fields` are present in `action`, they are validated via + * `options.validator`. A {@linkcode ValidationError} is thrown when + * `options.validator` returns a {@linkcode NegativeValidationResult}. If + * `options.validator` is not provided, then validation automatically passes. + * + * ```js + * submit(action, { + * validator: (fields) => { + * // ensure each field has a non-nullish value + * if (fields.every((field) => field.value != null)) + * return new PositiveValidationResult(); + * else + * return new NegativeValidationResult(); + * } + * }); + * ``` + * + * If validation passes, the `fields` are then serialized according to + * `options.serializer`, which receives `action`'s `type` and `fields`. If + * `options.serializer` is not provided, the {@linkcode defaultSerializer} is + * used. If `action.method` is `'GET'` or `'DELETE'`, the serialized content is + * placed in the query string. Otherwise, the content is placed in the request + * body. + * + * ```js + * import { defaultSerializer } from '@siren-js/client'; + * + * submit(action, { + * serializer: (type, fields) => { + * if (type === 'text/xml') + * return { + * content: // serialize fields to XML however you like... + * }; + * else + * // rely on defaultSerializer for any other type + * return defaultSerializer(type, field); + * } + * }) + * ``` + * + * @param action an {@linkcode Action} to submit + * @param options a {@linkcode SubmitOptions} object + * @returns a `Promise` that fulfills with an HTTP `Response` object + * @throws a {@linkcode ValidationError} when `options.validator` returns a {@linkcode NegativeValidationResult} */ export async function submit(action: Action, options: SubmitOptions = {}): Promise { const { method, href, fields } = action; @@ -45,7 +87,13 @@ export async function submit(action: Action, options: SubmitOptions = {}): Promi }; if (fields.length > 0) { - const { serializer = defaultSerializer } = options; + const validator = options.validator ?? noOpValidator; + const result = validator(fields); + if (result instanceof NegativeValidationResult) { + throw new ValidationError(result); + } + + const serializer = options.serializer ?? defaultSerializer; const serialization = await serializer(action.type, fields); if (init.method === 'GET' || init.method === 'DELETE') { diff --git a/src/validate/index.ts b/src/validate/index.ts new file mode 100644 index 0000000..99cc985 --- /dev/null +++ b/src/validate/index.ts @@ -0,0 +1,3 @@ +export * from './validation-error'; +export * from './validation-result'; +export * from './validator'; diff --git a/src/validate/no-op-validator.spec.ts b/src/validate/no-op-validator.spec.ts new file mode 100644 index 0000000..dcb6bc9 --- /dev/null +++ b/src/validate/no-op-validator.spec.ts @@ -0,0 +1,16 @@ +import 'reflect-metadata'; + +import { Field } from '../models'; +import { noOpValidator } from './no-op-validator'; +import { PositiveValidationResult } from './validation-result'; + +describe('noOpValidator', () => { + it('should always return a positive result', () => { + const invalidField = new Field(); + invalidField.type = 'number'; + invalidField.value = 'NaN'; + expect(noOpValidator([invalidField])).toBeInstanceOf(PositiveValidationResult); + expect(noOpValidator([new Field()])).toBeInstanceOf(PositiveValidationResult); + expect(noOpValidator([])).toBeInstanceOf(PositiveValidationResult); + }); +}); diff --git a/src/validate/no-op-validator.ts b/src/validate/no-op-validator.ts new file mode 100644 index 0000000..95bd3ad --- /dev/null +++ b/src/validate/no-op-validator.ts @@ -0,0 +1,4 @@ +import { PositiveValidationResult } from './validation-result'; +import { Validator } from './validator'; + +export const noOpValidator: Validator = () => new PositiveValidationResult(); diff --git a/src/validate/validation-error.ts b/src/validate/validation-error.ts new file mode 100644 index 0000000..2a0722e --- /dev/null +++ b/src/validate/validation-error.ts @@ -0,0 +1,10 @@ +import { NegativeValidationResult } from './validation-result'; + +/** + * Wraps a {@linkcode NegativeValidationResult} as an `Error` + */ +export class ValidationError extends Error { + constructor(readonly result: NegativeValidationResult) { + super('Validation failed'); + } +} diff --git a/src/validate/validation-result.ts b/src/validate/validation-result.ts new file mode 100644 index 0000000..c5fb134 --- /dev/null +++ b/src/validate/validation-result.ts @@ -0,0 +1,19 @@ +/** Base class for representing the result of performing validation */ +export abstract class ValidationResult {} + +/** Represents successful validation */ +export class PositiveValidationResult implements ValidationResult {} + +/** + * Represents failed validation. Inherit from this class for a more detailed + * result. For example: + * + * ```js + * class GoofedValidation extends NegativeValidationResult { + * constructor(readonly invalidFields: Field[]) { + * super(); + * } + * } + * ``` + */ +export class NegativeValidationResult implements ValidationResult {} diff --git a/src/validate/validator.ts b/src/validate/validator.ts new file mode 100644 index 0000000..5c50bb0 --- /dev/null +++ b/src/validate/validator.ts @@ -0,0 +1,7 @@ +import { Field } from '../models'; +import { ValidationResult } from './validation-result'; + +/** + * @returns a {@linkcode ValidationResult} indicating the validity of the given `fields` + */ +export type Validator = (fields: Field[]) => ValidationResult;