Compare commits

...
Author SHA1 Message Date
William Schurman 39093c3498 feat: add entity write validation 2020-06-23 20:43:20 -07:00
6 changed files with 233 additions and 20 deletions

No files matched your search

+4 -4
View File
@@ -11,7 +11,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number;
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>;
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>;
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
@@ -23,7 +23,7 @@ export default class EntityConfiguration<TFields> {
}: {
idField: keyof TFields;
tableName: string;
schema: Record<keyof TFields, EntityFieldDefinition>;
schema: Record<keyof TFields, EntityFieldDefinition<any>>;
cacheKeyVersion?: number;
}) {
this.idField = idField;
@@ -43,7 +43,7 @@ export default class EntityConfiguration<TFields> {
}
private static computeCacheableKeys<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlySet<keyof TFields> {
return reduceMap(
schema,
@@ -58,7 +58,7 @@ export default class EntityConfiguration<TFields> {
}
private static computeEntityToDBFieldsKeyMapping<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlyMap<keyof TFields, string> {
return mapMap(schema, (v) => v.columnName);
}
+33 -11
View File
@@ -1,6 +1,17 @@
export abstract class EntityFieldDefinition {
export interface FieldValidator<T> {
/**
* Validation to apply for this field before an entity is created or updated.
* Only applies to fields changed during a mutation; any existing fields will
* not be re-validated in order to allow for changing of validator logic.
*/
write?: (value: T) => Promise<void>;
}
export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly validator: FieldValidator<T>;
/**
*
* @param columnName - Column name in the database.
@@ -8,19 +19,30 @@ export abstract class EntityFieldDefinition {
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
constructor({ columnName, cache = false }: { columnName: string; cache?: boolean }) {
constructor({
columnName,
cache = false,
validator = {},
}: {
columnName: string;
cache?: boolean;
validator?: FieldValidator<T>;
}) {
this.columnName = columnName;
this.cache = cache;
this.validator = validator;
}
}
export class StringField extends EntityFieldDefinition {}
export class StringField extends EntityFieldDefinition<string> {}
export class UUIDField extends StringField {}
export class DateField extends EntityFieldDefinition {}
export class BooleanField extends EntityFieldDefinition {}
export class NumberField extends EntityFieldDefinition {}
export class StringArrayField extends EntityFieldDefinition {}
export class JSONObjectField extends EntityFieldDefinition {}
export class EnumField extends EntityFieldDefinition {}
export class JSONArrayField extends EntityFieldDefinition {}
export class MaybeJSONArrayField extends EntityFieldDefinition {}
export class DateField extends EntityFieldDefinition<Date> {}
export class BooleanField extends EntityFieldDefinition<boolean> {}
export class NumberField extends EntityFieldDefinition<number> {}
export class StringArrayField extends EntityFieldDefinition<string[]> {}
export class JSONObjectField<T extends object> extends EntityFieldDefinition<T> {}
export class EnumField<T> extends EntityFieldDefinition<T> {}
export class JSONArrayField<T> extends EntityFieldDefinition<T[]> {}
export class MaybeJSONArrayField<TArray, TNotArray> extends EntityFieldDefinition<
TArray[] | TNotArray
> {}
+33 -3
View File
@@ -1,4 +1,5 @@
import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results';
import invariant from 'invariant';
import _ from 'lodash';
import Entity, { IEntityClass } from './Entity';
@@ -40,6 +41,25 @@ abstract class BaseMutator<
protected readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {}
protected async validateFields(fields: Partial<TFields>): Promise<Result<Partial<TFields>>> {
const validatorResults = await asyncResult(
Promise.all(
Object.entries(fields).map(async ([fieldName, fieldValue]) => {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName as keyof TFields);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
const writeValidator = fieldDefinition.validator.write;
if (writeValidator) {
await writeValidator(fieldValue);
}
})
)
);
if (!validatorResults.ok) {
return result(validatorResults.reason);
}
return result(fields);
}
}
/**
@@ -84,9 +104,14 @@ export class CreateMutator<
}
private async createInternalAsync(): Promise<Result<TEntity>> {
const validatedFieldsResult = await this.validateFields(this.fieldsForEntity);
if (!validatedFieldsResult.ok) {
return result(validatedFieldsResult.reason);
}
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity,
...validatedFieldsResult.value,
} as unknown) as TFields);
const authorizeCreateResult = await asyncResult(
@@ -102,7 +127,7 @@ export class CreateMutator<
const insertResult = await this.databaseAdapter.insertAsync(
this.queryContext,
this.fieldsForEntity
validatedFieldsResult.value
);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
@@ -184,6 +209,11 @@ export class UpdateMutator<
}
private async updateInternalAsync(): Promise<Result<TEntity>> {
const validatedUpdatedFieldsResult = await this.validateFields(this.updatedFields);
if (!validatedUpdatedFieldsResult.ok) {
return result(validatedUpdatedFieldsResult.reason);
}
const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity);
const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync(
@@ -200,7 +230,7 @@ export class UpdateMutator<
this.queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
validatedUpdatedFieldsResult.value
);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
@@ -1,6 +1,6 @@
import { EntityFieldDefinition } from '../EntityFields';
class TestFieldDefinition extends EntityFieldDefinition {}
class TestFieldDefinition extends EntityFieldDefinition<any> {}
describe(EntityFieldDefinition, () => {
it('returns correct column name and defaults cache to false', () => {
@@ -12,4 +12,21 @@ describe(EntityFieldDefinition, () => {
expect(fieldDefinition2.columnName).toEqual('wat');
expect(fieldDefinition2.cache).toEqual(true);
});
it('defaults validator to no-op', () => {
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
expect(fieldDefinition.validator).toEqual({});
const fn = async (): Promise<void> => {
throw new Error();
};
const fieldDefinition2 = new TestFieldDefinition({
columnName: 'wat',
cache: true,
validator: {
write: fn,
},
});
expect(fieldDefinition2.validator.write).toEqual(fn);
});
});
@@ -30,6 +30,11 @@ import TestEntity, {
TestEntityPrivacyPolicy,
testEntityConfiguration,
} from '../testfixtures/TestEntity';
import ValidationTestEntity, {
ValidationTestEntityPrivacyPolicy,
ValidationTestFields,
validationTestEntityConfiguration,
} from '../testfixtures/ValidationTestEntity';
import { NoCacheStubCacheAdapterProvider } from '../utils/testing/StubCacheAdapter';
import StubDatabaseAdapter from '../utils/testing/StubDatabaseAdapter';
import StubQueryContextProvider from '../utils/testing/StubQueryContextProvider';
@@ -95,7 +100,7 @@ const createEntityMutatorFactory = (
};
describe(EntityMutatorFactory, () => {
it('creates entities and checks privacy', async () => {
it('creates entities checks privacy', async () => {
const viewerContext = mock<ViewerContext>();
const queryContext = mock<EntityQueryContext>();
const { privacyPolicy, entityMutatorFactory } = createEntityMutatorFactory([
@@ -246,6 +251,73 @@ describe(EntityMutatorFactory, () => {
expect(entities2).toHaveLength(2);
});
it('returns error when field not valid', async () => {
const viewerContext = mock<ViewerContext>();
const queryContext = mock<EntityQueryContext>();
const privacyPolicy = new ValidationTestEntityPrivacyPolicy();
const databaseAdapter = new StubDatabaseAdapter<ValidationTestFields>(
validationTestEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
validationTestEntityConfiguration,
new Map([[validationTestEntityConfiguration.tableName, []]])
)
);
const metricsAdapter = new NoOpEntityMetricsAdapter();
const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider();
const cacheAdapter = cacheAdapterProvider.getCacheAdapter(validationTestEntityConfiguration);
const entityCache = new ReadThroughEntityCache<ValidationTestFields>(
validationTestEntityConfiguration,
cacheAdapter
);
const dataManager = new EntityDataManager(
databaseAdapter,
entityCache,
StubQueryContextProvider,
metricsAdapter
);
const entityLoaderFactory = new EntityLoaderFactory(
validationTestEntityConfiguration,
ValidationTestEntity,
privacyPolicy,
dataManager
);
const entityMutatorFactory = new EntityMutatorFactory(
validationTestEntityConfiguration,
ValidationTestEntity,
privacyPolicy,
entityLoaderFactory,
databaseAdapter,
metricsAdapter
);
const entityCreateInvalidResult = await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.setField('numberOtherThanTen', 10)
.createAsync();
expect(entityCreateInvalidResult.ok).toBe(false);
expect(entityCreateInvalidResult.reason?.message).toEqual('should not be ten');
const entityCreateValidResult = await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.setField('numberOtherThanTen', 11)
.createAsync();
expect(entityCreateValidResult.ok).toBe(true);
const entityUpdateInvalidResult = await entityMutatorFactory
.forUpdate(entityCreateValidResult.enforceValue(), queryContext)
.setField('numberOtherThanTen', 10)
.updateAsync();
expect(entityUpdateInvalidResult.ok).toBe(false);
expect(entityUpdateInvalidResult.reason?.message).toEqual('should not be ten');
const entityUpdateValidResult = await entityMutatorFactory
.forUpdate(entityCreateValidResult.enforceValue(), queryContext)
.setField('numberOtherThanTen', 12)
.updateAsync();
expect(entityUpdateValidResult.ok).toBe(true);
});
it('returns error result when not authorized to create', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityNonTransactionalQueryContext));
@@ -0,0 +1,72 @@
import Entity from '../Entity';
import {
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition,
} from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField, NumberField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
export type ValidationTestFields = {
id: string;
numberOtherThanTen: number;
};
export const validationTestEntityConfiguration = new EntityConfiguration<ValidationTestFields>({
idField: 'id',
tableName: 'validation_test_entity',
schema: {
id: new UUIDField({
columnName: 'custom_id',
}),
numberOtherThanTen: new NumberField({
columnName: 'not_ten',
validator: {
async write(value) {
if (value === 10) {
throw new Error('should not be ten');
}
},
},
}),
},
});
export class ValidationTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ValidationTestFields,
string,
ViewerContext,
ValidationTestEntity
> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()];
}
export default class ValidationTestEntity extends Entity<
ValidationTestFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
ValidationTestFields,
string,
ViewerContext,
ValidationTestEntity,
ValidationTestEntityPrivacyPolicy
> {
return validationTestEntityCompanion;
}
}
export const validationTestEntityCompanion = {
entityClass: ValidationTestEntity,
entityConfiguration: validationTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: ValidationTestEntityPrivacyPolicy,
};