Skip to content

Commit

Permalink
Support for client-side validation
Browse files Browse the repository at this point in the history
Resolves #16
  • Loading branch information
dillonredding committed Sep 2, 2023
1 parent 4cbafd8 commit 85438a7
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 16 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
16 changes: 15 additions & 1 deletion src/submit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -91,7 +103,7 @@ describe('submit', () => {
</${action.name}>
`;
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);
Expand All @@ -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', () => {
Expand Down
72 changes: 60 additions & 12 deletions src/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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<Response> {
const { method, href, fields } = action;
Expand All @@ -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') {
Expand Down
3 changes: 3 additions & 0 deletions src/validate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './validation-error';
export * from './validation-result';
export * from './validator';
16 changes: 16 additions & 0 deletions src/validate/no-op-validator.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 4 additions & 0 deletions src/validate/no-op-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { PositiveValidationResult } from './validation-result';
import { Validator } from './validator';

export const noOpValidator: Validator = () => new PositiveValidationResult();
10 changes: 10 additions & 0 deletions src/validate/validation-error.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
19 changes: 19 additions & 0 deletions src/validate/validation-result.ts
Original file line number Diff line number Diff line change
@@ -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 {}
7 changes: 7 additions & 0 deletions src/validate/validator.ts
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 85438a7

Please sign in to comment.