Skip to content

Commit

Permalink
feat(schema): Improve schema typing, validation and extensibility (#2521
Browse files Browse the repository at this point in the history
)
  • Loading branch information
daffl authored Jan 7, 2022
1 parent 6109c44 commit 8c1b350
Show file tree
Hide file tree
Showing 4 changed files with 74 additions and 68 deletions.
8 changes: 0 additions & 8 deletions packages/schema/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,3 @@ export const queryProperty = <T extends JSONSchema> (definition: T) => ({
}
]
} as const);

export const queryArray = <T extends readonly string[]> (fields: T) => ({
type: 'array',
items: {
type: 'string',
enum: fields
}
} as const);
40 changes: 15 additions & 25 deletions packages/schema/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,34 @@
import Ajv, { AsyncValidateFunction } from 'ajv';
import { JSONSchema6 } from 'json-schema';
import Ajv, { AsyncValidateFunction, ValidateFunction } from 'ajv';
import { FromSchema, JSONSchema } from 'json-schema-to-ts';
import { BadRequest } from '@feathersjs/errors';

export const AJV = new Ajv({
coerceTypes: true
});

export type JSONSchemaDefinition = JSONSchema & { $id: string };
export type JSONSchemaDefinition = JSONSchema & { $id: string, $async?: boolean };

export class Schema<S extends JSONSchemaDefinition> {
ajv: Ajv;
validate: AsyncValidateFunction<FromSchema<S>>;
definition: JSONSchema6;
validator: AsyncValidateFunction;
readonly _type!: FromSchema<S>;

constructor (definition: S, ajv: Ajv = AJV) {
constructor (public definition: S, ajv: Ajv = AJV) {
this.ajv = ajv;
this.definition = definition as JSONSchema6;
this.validate = this.ajv.compile({
this.validator = this.ajv.compile({
$async: true,
...this.definition
});
...(this.definition as any)
}) as AsyncValidateFunction;
}

get propertyNames () {
return Object.keys(this.definition.properties || {});
}
async validate <T = FromSchema<S>> (...args: Parameters<ValidateFunction<T>>) {
try {
const validated = await this.validator(...args) as T;

extend <D extends JSONSchemaDefinition> (definition: D) {
const def = definition as JSONSchema6;
const extended = {
...this.definition,
...def,
properties: {
...this.definition.properties,
...def.properties
}
} as const;

return new Schema <D & S> (extended as any, this.ajv);
return validated;
} catch (error: any) {
throw new BadRequest(error.message, error.errors);
}
}

toJSON () {
Expand Down
55 changes: 31 additions & 24 deletions packages/schema/test/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { memory, Service } from '@feathersjs/memory';

import {
schema, resolve, Infer, resolveResult,
queryProperty, queryArray, resolveQuery,
queryProperty, resolveQuery,
validateQuery, validateData, resolveData
} from '../src';

Expand All @@ -20,12 +20,16 @@ export const userSchema = schema({
}
} as const);

export const userResultSchema = userSchema.extend({
export const userResultSchema = schema({
$id: 'UserResult',
type: 'object',
additionalProperties: false,
required: ['id', ...userSchema.definition.required ],
properties: {
...userSchema.definition.properties,
id: { type: 'number' }
}
});
} as const);

export type User = Infer<typeof userSchema>;
export type UserResult = Infer<typeof userResultSchema>;
Expand Down Expand Up @@ -57,20 +61,38 @@ export const messageSchema = schema({
}
} as const);

export const messageResultSchema = messageSchema.extend({
export const messageResultSchema = schema({
$id: 'MessageResult',
type: 'object',
additionalProperties: false,
required: ['id', 'user', ...messageSchema.definition.required],
properties: {
...messageSchema.definition.properties,
id: { type: 'number' },
user: { $ref: 'UserResult' }
}
} as const);

export type Message = Infer<typeof messageSchema>;
export type MessageResult = Infer<typeof messageResultSchema> & {
user: User;
};

export const messageResultResolver = resolve<MessageResult, HookContext<Application>>({
properties: {
user: async (_value, message, context) => {
const { userId } = message;

return context.app.service('users').get(userId, context.params);
}
}
});

export const messageQuerySchema = schema({
$id: 'MessageQuery',
type: 'object',
additionalProperties: false,
properties: {
$resolve: queryArray(messageResultSchema.propertyNames),
$limit: {
type: 'number',
minimum: 0,
Expand All @@ -79,6 +101,10 @@ export const messageQuerySchema = schema({
$skip: {
type: 'number'
},
$resolve: {
type: 'array',
items: { type: 'string' }
},
userId: queryProperty({
type: 'number'
})
Expand All @@ -89,10 +115,6 @@ export type MessageQuery = Infer<typeof messageQuerySchema>;

export const messageQueryResolver = resolve<MessageQuery, HookContext<Application>>({
properties: {
$resolve: async (value) => {
return value || messageResultSchema.propertyNames;
},

userId: async (value, _query, context) => {
if (context.params?.user) {
return context.params.user.id;
Expand All @@ -103,21 +125,6 @@ export const messageQueryResolver = resolve<MessageQuery, HookContext<Applicatio
}
});

export type Message = Infer<typeof messageSchema>;
export type MessageResult = Infer<typeof messageResultSchema> & {
user: User;
};

export const messageResultResolver = resolve<MessageResult, HookContext<Application>>({
properties: {
user: async (_value, message, context) => {
const { userId } = message;

return context.app.service('users').get(userId, context.params);
}
}
});

type ServiceTypes = {
users: Service<UserResult, User>,
messages: Service<MessageResult, Message>
Expand Down
39 changes: 28 additions & 11 deletions packages/schema/test/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import assert from 'assert';

import { schema, Infer, queryProperty } from '../src';
import Ajv, { AnySchemaObject } from 'ajv'
import addFormats from 'ajv-formats'
import Ajv, { AnySchemaObject } from 'ajv';
import addFormats from 'ajv-formats';

const customAjv = new Ajv({
coerceTypes: true
Expand Down Expand Up @@ -46,7 +46,7 @@ describe('@feathersjs/schema/schema', () => {
} as const);
type Message = Infer<typeof messageSchema>;

const message: Message = await messageSchema.validate({
const message = await messageSchema.validate<Message>({
text: 'hi',
read: 0,
upvotes: '10'
Expand All @@ -58,6 +58,19 @@ describe('@feathersjs/schema/schema', () => {
read: false,
upvotes: 10
});

await assert.rejects(() => messageSchema.validate({ text: 'failing' }), {
name: 'BadRequest',
data: [{
instancePath: '',
keyword: 'required',
message: 'must have required property \'read\'',
params: {
missingProperty: 'read'
},
schemaPath: '#/required'
}]
});
});

it('uses custom AJV with format validation', async () => {
Expand Down Expand Up @@ -87,7 +100,7 @@ describe('@feathersjs/schema/schema', () => {
createdAt: '2021-12-22T23:59:59.bbb'
});
} catch (error: any) {
assert.equal(error.errors[0].message, 'must match format "date-time"')
assert.equal(error.data[0].message, 'must match format "date-time"')
}
});

Expand Down Expand Up @@ -135,10 +148,14 @@ describe('@feathersjs/schema/schema', () => {
}
}
} as const);
const messageResultSchema = messageSchema.extend({

const messageResultSchema = schema({
$id: 'message-ext-vote',
required: [ 'upvotes' ],
type: 'object',
required: [ 'upvotes', ...messageSchema.definition.required ],
additionalProperties: false,
properties: {
...messageSchema.definition.properties,
upvotes: {
type: 'number'
}
Expand All @@ -147,7 +164,7 @@ describe('@feathersjs/schema/schema', () => {

type MessageResult = Infer<typeof messageResultSchema>;

const m: MessageResult = await messageResultSchema.validate({
const m = await messageResultSchema.validate<MessageResult>({
text: 'Hi',
read: 'false',
upvotes: '23'
Expand All @@ -160,11 +177,12 @@ describe('@feathersjs/schema/schema', () => {
});
});

it('with references and type extension', async () => {
it('with references', async () => {
const userSchema = schema({
$id: 'ref-user',
type: 'object',
required: [ 'email' ],
additionalProperties: false,
properties: {
email: { type: 'string' },
age: { type: 'number' }
Expand All @@ -190,14 +208,13 @@ describe('@feathersjs/schema/schema', () => {
user: User
};

// TODO find a way to not have to force cast this
const res = await messageSchema.validate({
const res = await messageSchema.validate<Message>({
text: 'Hello',
user: {
email: '[email protected]',
age: '42'
}
}) as Message;
});

assert.ok(userSchema);
assert.deepStrictEqual(res, {
Expand Down

0 comments on commit 8c1b350

Please sign in to comment.