Skip to content

Commit

Permalink
feat(annotatee+raw): added annotate() and the raw validator
Browse files Browse the repository at this point in the history
  • Loading branch information
grantila committed Mar 28, 2021
1 parent ed134b2 commit 4ed6ac1
Show file tree
Hide file tree
Showing 23 changed files with 385 additions and 92 deletions.
37 changes: 28 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const data = ... // get data from somewhere, e.g. as a TypeScript unknown

## Standard validator

The default behaviour of `compile` is to return a validator function returning decorated Ajv output.
The default behaviour of `compile` is to return a validator function returning extended Ajv output.

```ts
const userValidator = compile( userSchema );
Expand Down Expand Up @@ -147,28 +147,47 @@ const user = ensureUser< User >( data );
```


# Decorating schemas
## Raw JSON Schema validator

You can decorate a validator schema using `suretype()`. The return value is still a validator schema, but when exporting it, the decorations will be included.
Sometimes it's handy to not describe the validator schema programmatically, but rather use a raw JSON Schema. There will be no type deduction, so the corresponding interface must be provided explicitly. Only use this if you know the JSON Schema maps to the interface! `raw` works just like the `v.*` functions and returns a validator schema. It can also be annotated.

```ts
import { suretype, v } from "suretype"
import { raw, compile } from 'suretype'

type User = ...; // Get this type from somewhere
const userSchema = raw< User >( { type: 'object', properties: { /* ... */ } } );

// Compile as usual
const ensureUser = compile( userSchema, { ensure: true } );
```


# Annotating schemas

You can annotate a validator schema using `suretype()` or `annotate()`. The return value is still a validator schema, but when exporting it, the annotations will be included.

The difference between `suretype()` and `annotate()` is that `suretype()` requires the `name` property, where as it's optional in `annotate()`. Use `suretype()` to annotate top-level schemas so that they have proper names in the corresponding JSON Schema.

Annotations are useful when exporting the schema to other formats (e.g. JSON Schema or pretty TypeScript interfaces).

```ts
import { suretype, annotate, v } from "suretype"

const cartItemSchema = suretype(
// Decorations
// Annotations
{ name: "CartItem" },
// The validator schema
v.object( {
productId: v.string( ),
productId: annotate( { title: "The product id string" }, v.string( ) ),
// ...
} )
);
```

The decorator interface (i.e. the fields you can decorate) is:
The interface (i.e. the fields you can use) is called `Annotations`:

```ts
interface Decorations {
interface Annotations {
name: string;
title?: string;
description?: string;
Expand All @@ -183,7 +202,7 @@ where only the `name` is required.

The following are two types, one using (or *depending on*) the other. They are *named*, which will be reflected in the JSON schema, shown below.

The `userSchema` is the same as in the above example, although it's wrapped in `suretype()` which decorates it with a name and other attributes.
The `userSchema` is the same as in the above example, although it's wrapped in `suretype()` which annotates it with a name and other attributes.

<details style="padding-left: 32px;border-left: 4px solid gray;">
<summary>Given these validation schemas:</summary>
Expand Down
38 changes: 38 additions & 0 deletions lib/annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { BaseValidator } from "./validators/base/validator"

export interface Annotations
{
name?: string;
title?: string;
description?: string;
examples?: Array< string >;
}

export type TopLevelAnnotations =
Omit< Annotations, 'name' > &
Required< Pick< Annotations, 'name' > >;

export class AnnotationsHolder
{
constructor( public options: Annotations )
{ }
}

type AnnotatedValidator< T extends BaseValidator< unknown, any > > =
T & { _annotations: AnnotationsHolder };

export function annotateValidator< T extends BaseValidator< unknown > >(
validator: T,
annotations: AnnotationsHolder
): T
{
( validator as AnnotatedValidator< T > )._annotations = annotations;
return validator;
}

export function getAnnotations< T extends BaseValidator< unknown > >(
validator: T
): Annotations | undefined
{
return ( validator as AnnotatedValidator< T > )._annotations?.options;
}
43 changes: 36 additions & 7 deletions lib/api/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { suretype, v, ensureNamed } from './index'
import { suretype, annotate, v, ensureNamed } from './index'
import { compile } from '..'
import { getDecorations } from '../validation'
import { getAnnotations } from '../annotations'


describe( "suretype", ( ) =>
Expand Down Expand Up @@ -34,6 +34,36 @@ describe( "suretype", ( ) =>
} );
} );

describe( "annotate", ( ) =>
{
it( "should validate a annotate()'d schema", ( ) =>
{
const inner = v.object( {
foo: v.string( ).const( "bar" ),
bar: v.number( ).gt( 17 ).required( ),
} );
const schema = annotate(
{
description: "Description",
title: "Title",
examples: [ "Example" ],
},
inner
);

const innerValidator = compile( inner );
const outerValidator = compile( schema );

const valid = { bar: 20 };
const invalid = { foo: 30, bar: 20 };

expect( innerValidator( valid ).ok ).toBe( true );
expect( innerValidator( invalid ).ok ).toBe( false );
expect( outerValidator( valid ).ok ).toBe( true );
expect( outerValidator( invalid ).ok ).toBe( false );
} );
} );

describe( "v", ( ) =>
{
it( "should compile and validate a validator schema", ( ) =>
Expand All @@ -47,23 +77,22 @@ describe( "v", ( ) =>
} );
} );


describe( "ensureNamed", ( ) =>
{
it( "should not change name of decorated validator", ( ) =>
it( "should not change name of annotated validator", ( ) =>
{
const schema = suretype(
{ name: 'Goodname' },
v.object( { foo: v.string( ) } )
);
const validator = ensureNamed( 'Badname', schema );
expect( getDecorations( validator )?.options.name ).toBe( 'Goodname' );
expect( getAnnotations( validator )?.name ).toBe( 'Goodname' );
} );

it( "should change name of non-decorated validator", ( ) =>
it( "should change name of non-annotated validator", ( ) =>
{
const schema = v.object( { foo: v.string( ) } );
const validator = ensureNamed( 'Goodname', schema );
expect( getDecorations( validator )?.options.name ).toBe( 'Goodname' );
expect( getAnnotations( validator )?.name ).toBe( 'Goodname' );
} );
} );
72 changes: 53 additions & 19 deletions lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,20 @@ import { TupleValidator } from "../validators/tuple/validator"
import { AnyOfValidator } from "../validators/or/validator"
import { AllOfValidator } from "../validators/all-of/validator"
import { IfValidator } from "../validators/if/validator"
import { RawValidator } from "../validators/raw/validator"
import { RecursiveValidator } from "../validators/recursive/validator"
import { TypeOf } from "../validators/functional"
import {
cloneValidator,
decorateValidator,
getDecorations,
} from "../validation"
import { cloneValidator } from "../validation"
import { ArrayFunction, TupleFunction } from "../validators/array-types"
import { ExtractObject } from "../validators/object-types"
import { DecorationsHolder, Decorations } from "../validators/decorations"
import {
AnnotationsHolder,
Annotations,
TopLevelAnnotations,
annotateValidator,
getAnnotations,
} from "../annotations"
import { RecursiveValue } from "../validators/types"


const string = ( ) => new StringValidator( );
Expand Down Expand Up @@ -61,6 +66,8 @@ const any = ( ) => new AnyValidator( );
const _if = < T extends BaseValidator< unknown > >( validator: T ) =>
new IfValidator< TypeOf< T > >( validator );

const recursive = ( ) => new RecursiveValidator( );

export const v = {
string,
number,
Expand All @@ -72,46 +79,73 @@ export const v = {
allOf,
if: _if,
any,
recursive,
};

/**
* Decorate a validator with a name and other annotations
* Cast a recursive value (a value in a recursive type)
*/
export const recursiveCast = < T >( value: RecursiveValue ): T => value as any;

/**
* Cast a value into a recursive value (inversion of recursiveCast)
*/
export const recursiveUnCast = < T >( value: T ) => value as RecursiveValue;

export const raw = < T = unknown >( jsonSchema: any ) =>
new RawValidator( jsonSchema ) as BaseValidator< T >;

/**
* Annotate a validator with a name and other decorations
*
* @param decorations Decorations
* @param validator Target validator to decorate
* @returns Decorated validator
* @param annotations Annotations
* @param validator Target validator to annotate
* @returns Annotated validator
*/
export function suretype< T extends BaseValidator< unknown, any > >(
decorations: Decorations,
annotations: TopLevelAnnotations,
validator: T
)
: T
{
return annotateValidator(
cloneValidator( validator, false ),
new AnnotationsHolder( annotations )
);
}

export function annotate< T extends BaseValidator< unknown, any > >(
annotations: Partial< Annotations >,
validator: T
)
: T
{
return decorateValidator(
return annotateValidator(
cloneValidator( validator, false ),
new DecorationsHolder( decorations )
new AnnotationsHolder( annotations )
);
}

/**
* Ensures a validator is decorated with a name. This will not overwrite the
* Ensures a validator is annotated with a name. This will not overwrite the
* name of a validator, only ensure it has one.
*
* @param name The name to decorate with, unless already decorated
* @param name The name to annotate with, unless already annotated
* @param validator The target validator
* @returns Decorated validator
* @returns Annotated validator
*/
export function ensureNamed< T extends BaseValidator< unknown, any > >(
name: string,
validator: T
)
: T
{
if ( getDecorations( validator )?.options.name )
const annotations = getAnnotations( validator );
if ( annotations?.name )
return validator;

return decorateValidator(
return annotateValidator(
cloneValidator( validator, false ),
new DecorationsHolder( { name } )
new AnnotationsHolder( { ...annotations, name } )
);
}
2 changes: 1 addition & 1 deletion lib/extract-json-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe( "extract-json-schema", ( ) =>
const schema2 = v.string( );

expect( ( ) => extractJsonSchema( [ schema1, schema2 ] ) )
.toThrowError( /undecorated/ );
.toThrowError( /unnamed/ );
} );

it( "should ignore non-decorated validator schemas", ( ) =>
Expand Down
12 changes: 6 additions & 6 deletions lib/extract-json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
} from "./types"
import { DuplicateError } from "./errors"
import { BaseValidator } from "./validators/base/validator"
import { getDecorations } from "./validation"
import { getAnnotations } from "./annotations"
import { TreeTraverserImpl } from "./tree-traverser"


Expand Down Expand Up @@ -40,24 +40,24 @@ export function extractJsonSchema(
if ( onNonSuretypeValidator === 'ignore' )
{
validators = validators
.filter( validator => getDecorations( validator ) );
.filter( validator => getAnnotations( validator )?.name );
}
else if ( onNonSuretypeValidator === 'error' )
{
validators.forEach( validator =>
{
if ( !getDecorations( validator ) )
throw new TypeError( "Got undecorated validator" );
if ( !getAnnotations( validator )?.name )
throw new TypeError( "Got unnamed validator" );
} );
}

if ( onTopLevelNameConflict === 'error' )
{
const nameSet = new Set< string >( );
validators
.map( validator => getDecorations( validator ) )
.map( validator => getAnnotations( validator )?.name )
.filter( < T >( t: T ): t is NonNullable< T > => !!t )
.forEach( ( { options: { name } } ) =>
.forEach( name =>
{
if ( nameSet.has( name ) )
throw new DuplicateError(
Expand Down
7 changes: 7 additions & 0 deletions lib/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ describe( "index", ( ) =>
allOf: expect.any( Function ),
if: expect.any( Function ),
any: expect.any( Function ),
recursive: expect.any( Function ),
},
raw: expect.any( Function ),
suretype: expect.any( Function ),
annotate: expect.any( Function ),
recursiveCast: expect.any( Function ),
recursiveUnCast: expect.any( Function ),

// JSON Schema extraction
extractJsonSchema: expect.any( Function ),
extractSingleJsonSchema: expect.any( Function ),
// Annotations
getAnnotations: expect.any( Function ),

// Errors
DuplicateConstraintError: expect.any( Function ),
Expand Down
6 changes: 6 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,11 @@ export {

export { getValidatorSchema } from "./validation"

export {
Annotations,
TopLevelAnnotations,
getAnnotations,
} from "./annotations"

import type { TypeOf } from "./validators/functional"
export type { TypeOf }
Loading

0 comments on commit 4ed6ac1

Please sign in to comment.