From 8c1b35052792e82d13be03c06583534284fbae82 Mon Sep 17 00:00:00 2001 From: David Luecke Date: Fri, 7 Jan 2022 11:30:08 -0800 Subject: [PATCH] feat(schema): Improve schema typing, validation and extensibility (#2521) --- packages/schema/src/query.ts | 8 ----- packages/schema/src/schema.ts | 40 ++++++++------------- packages/schema/test/fixture.ts | 55 ++++++++++++++++------------- packages/schema/test/schema.test.ts | 39 ++++++++++++++------ 4 files changed, 74 insertions(+), 68 deletions(-) diff --git a/packages/schema/src/query.ts b/packages/schema/src/query.ts index 8790040b4d..712a4ed8a0 100644 --- a/packages/schema/src/query.ts +++ b/packages/schema/src/query.ts @@ -24,11 +24,3 @@ export const queryProperty = (definition: T) => ({ } ] } as const); - -export const queryArray = (fields: T) => ({ - type: 'array', - items: { - type: 'string', - enum: fields - } -} as const); diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index bf58f81f64..329aa73e49 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -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 { ajv: Ajv; - validate: AsyncValidateFunction>; - definition: JSONSchema6; + validator: AsyncValidateFunction; readonly _type!: FromSchema; - 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 > (...args: Parameters>) { + try { + const validated = await this.validator(...args) as T; - extend (definition: D) { - const def = definition as JSONSchema6; - const extended = { - ...this.definition, - ...def, - properties: { - ...this.definition.properties, - ...def.properties - } - } as const; - - return new Schema (extended as any, this.ajv); + return validated; + } catch (error: any) { + throw new BadRequest(error.message, error.errors); + } } toJSON () { diff --git a/packages/schema/test/fixture.ts b/packages/schema/test/fixture.ts index 8f78c8a34c..df9cf86b43 100644 --- a/packages/schema/test/fixture.ts +++ b/packages/schema/test/fixture.ts @@ -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'; @@ -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; export type UserResult = Infer; @@ -57,12 +61,31 @@ 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; +export type MessageResult = Infer & { + user: User; +}; + +export const messageResultResolver = resolve>({ + properties: { + user: async (_value, message, context) => { + const { userId } = message; + + return context.app.service('users').get(userId, context.params); + } + } }); export const messageQuerySchema = schema({ @@ -70,7 +93,6 @@ export const messageQuerySchema = schema({ type: 'object', additionalProperties: false, properties: { - $resolve: queryArray(messageResultSchema.propertyNames), $limit: { type: 'number', minimum: 0, @@ -79,6 +101,10 @@ export const messageQuerySchema = schema({ $skip: { type: 'number' }, + $resolve: { + type: 'array', + items: { type: 'string' } + }, userId: queryProperty({ type: 'number' }) @@ -89,10 +115,6 @@ export type MessageQuery = Infer; export const messageQueryResolver = resolve>({ properties: { - $resolve: async (value) => { - return value || messageResultSchema.propertyNames; - }, - userId: async (value, _query, context) => { if (context.params?.user) { return context.params.user.id; @@ -103,21 +125,6 @@ export const messageQueryResolver = resolve; -export type MessageResult = Infer & { - user: User; -}; - -export const messageResultResolver = resolve>({ - properties: { - user: async (_value, message, context) => { - const { userId } = message; - - return context.app.service('users').get(userId, context.params); - } - } -}); - type ServiceTypes = { users: Service, messages: Service diff --git a/packages/schema/test/schema.test.ts b/packages/schema/test/schema.test.ts index 15a01d2029..f838159593 100644 --- a/packages/schema/test/schema.test.ts +++ b/packages/schema/test/schema.test.ts @@ -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 @@ -46,7 +46,7 @@ describe('@feathersjs/schema/schema', () => { } as const); type Message = Infer; - const message: Message = await messageSchema.validate({ + const message = await messageSchema.validate({ text: 'hi', read: 0, upvotes: '10' @@ -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 () => { @@ -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"') } }); @@ -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' } @@ -147,7 +164,7 @@ describe('@feathersjs/schema/schema', () => { type MessageResult = Infer; - const m: MessageResult = await messageResultSchema.validate({ + const m = await messageResultSchema.validate({ text: 'Hi', read: 'false', upvotes: '23' @@ -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' } @@ -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({ text: 'Hello', user: { email: 'hello@feathersjs.com', age: '42' } - }) as Message; + }); assert.ok(userSchema); assert.deepStrictEqual(res, {