Created
February 25, 2020 15:53
-
-
Save TomMarius/4e32706db75c6795cda91fa5db6c876c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'reflect-metadata' | |
| import { __decorate, __param, __metadata } from 'tslib' | |
| import { ApolloServer, AuthenticationError, ForbiddenError } from 'apollo-server' | |
| import { | |
| EntityManager, | |
| createConnection, | |
| Entity, | |
| PrimaryGeneratedColumn, | |
| CreateDateColumn, | |
| Column, | |
| Index, | |
| Brackets, | |
| } from 'typeorm' | |
| import { | |
| Resolver, | |
| FieldResolver, | |
| Query, | |
| Mutation, | |
| ID, | |
| Root, | |
| InputType, | |
| Field, | |
| buildSchema, | |
| Ctx, | |
| ObjectType, | |
| Arg, | |
| ClassType, | |
| } from 'type-graphql' | |
| import { createHash } from 'crypto' | |
| export enum CRUD { | |
| Create, | |
| RetrieveList, | |
| RetrieveByID, | |
| UpdateByID, | |
| PatchByID, | |
| DeleteByID, | |
| // TODO fulltext search (also need to make one that is not bound to a single entity... | |
| // or maybe an entity should be a root but it should be able to search in related entities as well?) | |
| } | |
| export const Primitives: { [key: string]: any } = { | |
| string: String, | |
| number: Number, | |
| boolean: Boolean, | |
| bool: Boolean, | |
| text: String, | |
| numeric: String, | |
| } | |
| export interface AuthObject { | |
| readTokenOrThrow: (token: string) => Promise<{ userID: string }> | |
| isTokenInvalidated: (token: string) => Promise<string> | |
| getUserOrThrow: (userID: string) => Promise<any> | |
| } | |
| export interface AuthFuncs { | |
| canUserReadField: (user: any, entity: any, key: string) => Promise<boolean> | |
| canUserWriteField: (user: any, entity: any, key: string, value: any) => Promise<boolean> | |
| canUserCreateEntity: (user: any, input: any) => Promise<boolean> | |
| canUserListEntities: (user: any) => Promise<boolean> | |
| canUserRetrieveEntity: (user: any, entity: any) => Promise<boolean> | |
| canUserUpdateEntity: (user: any, entity: any, input: any) => Promise<boolean> | |
| canUserDeleteEntity: (user: any, entity: any) => Promise<boolean> | |
| } | |
| export enum PermissionKind { | |
| Read, | |
| Write, | |
| Create, | |
| Retrieve, | |
| List, | |
| Update, | |
| Delete, | |
| All, | |
| } | |
| @Entity() | |
| export class Permission { | |
| @PrimaryGeneratedColumn('uuid') | |
| id!: string | |
| @CreateDateColumn() | |
| createdAt!: Date | |
| @Column('timestamp without time zone', { nullable: true }) | |
| deletedAt?: Date | |
| @Index() | |
| @Column('text') | |
| roleID?: string | |
| @Index() | |
| @Column('text') | |
| entityName!: string | |
| @Index() | |
| @Column('text', { nullable: true }) | |
| entityID!: string // limits permission to specific entity | |
| @Index() | |
| @Column('text', { nullable: true }) | |
| fieldName?: string | |
| @Index() | |
| @Column({ | |
| type: 'enum', | |
| enum: PermissionKind, | |
| }) | |
| kind!: PermissionKind | |
| } | |
| export function EntityResolver<T>( | |
| entityManager: EntityManager, | |
| constructor: ClassType, | |
| options: { | |
| tokenContextSelector: string | |
| softDeleteSelector?: string | |
| softDelete?: boolean | |
| crud: CRUD[] | |
| auth?: (AuthObject | undefined) & AuthFuncs | |
| noAuth?: boolean | |
| }, | |
| ) { | |
| const softDeleteSelector = options.softDeleteSelector | |
| const tokenContextSelector = options.tokenContextSelector | |
| // declare output class | |
| @Resolver(() => constructor) | |
| class Output {} | |
| // obtain repository | |
| const entityRepository = entityManager.getRepository<T>(constructor) | |
| // entity metadata | |
| const entityMetadata = entityRepository.metadata | |
| const entityName = entityMetadata.name | |
| // combine default auth checkers with custom ones | |
| const permissionRepository = entityManager.getRepository(Permission) | |
| // authentication | |
| async function verifyTokenAndRetrieveUserOrThrow(token: string) { | |
| // possibilities: | |
| // - Ani zvalid | |
| // - token is expired | |
| // - token is invalidated | |
| // - user does not exist | |
| // - user is (soft) deleted | |
| // - user is disabled | |
| // 1) decrypt the token, fail if expired or otherwise invalid using the readTokenOrThrow | |
| // 2) check if the token is invalidated using the isTokenInvalidated function | |
| // 3) try to pull the user using the getUserOrThrow function | |
| let message = '' | |
| try { | |
| message = 'Token is invalid' | |
| const { userID } = await auth.readTokenOrThrow(token) | |
| if (await auth.isTokenInvalidated(token)) { | |
| message = 'Token is invalidated' | |
| throw new AuthenticationError(message) | |
| } | |
| message = 'User not found' | |
| const user = await auth.getUserOrThrow(userID) | |
| return user | |
| } catch (e) { | |
| throw new AuthenticationError(message) | |
| } | |
| } | |
| async function checkUserHasPermission( | |
| token: string, | |
| entity: any, | |
| fieldName: string | null, | |
| permissionKinds: PermissionKind[], | |
| ) { | |
| if (options.noAuth) { | |
| return true | |
| } | |
| const user = await verifyTokenAndRetrieveUserOrThrow(token) | |
| const query = permissionRepository | |
| .createQueryBuilder('permission') | |
| .where('permission.deletedAt IS NULL') | |
| .andWhere('permission.entityName = :entityName', { entityName }) | |
| .andWhere( | |
| new Brackets((qb1) => { | |
| // where | |
| qb1.where('permission.entityID IS NULL').orWhere('permission.entityID = :entityID', { | |
| entityID: entity.id, | |
| }) | |
| }), | |
| ) | |
| .andWhere( | |
| new Brackets((qb1) => { | |
| qb1.where('permission.userID = :userID', { userID: user.id }).orWhere('TODO groups') | |
| }), | |
| ) | |
| .andWhere('permission.kind IN (:...kinds)', { | |
| kinds: permissionKinds, | |
| }) | |
| // if there is fieldName in the permission row, add to conditions | |
| if (fieldName != null) { | |
| query.andWhere('permission.fieldName = :fieldName', { fieldName }) | |
| } | |
| const permissionsCount = await query.getCount() | |
| if (permissionsCount > 0) { | |
| return true | |
| } | |
| return false | |
| } | |
| const auth: AuthObject & AuthFuncs = { | |
| canUserReadField: async (user, entity, key) => checkUserHasPermission(user, entity, key, [PermissionKind.Read]), | |
| canUserWriteField: async (user, entity, key) => | |
| checkUserHasPermission(user, entity, key, [PermissionKind.Write]), | |
| canUserCreateEntity: async (user, input) => checkUserHasPermission(user, input, null, [PermissionKind.Create]), | |
| canUserListEntities: async (user) => checkUserHasPermission(user, null, null, [PermissionKind.List]), | |
| canUserRetrieveEntity: async (user, entity) => | |
| checkUserHasPermission(user, entity, null, [PermissionKind.Retrieve]), | |
| canUserUpdateEntity: async (user, entity, input) => | |
| checkUserHasPermission(user, entity, null, [PermissionKind.Update]), | |
| canUserDeleteEntity: async (user, entity) => | |
| checkUserHasPermission(user, entity, null, [PermissionKind.Delete]), | |
| ...options.auth!, | |
| } | |
| async function assertAuth(func: Function, error: string, ...args: any[]) { | |
| if (await func(...args)) { | |
| return true | |
| } | |
| throw new ForbiddenError(`Permission not found`) | |
| } | |
| // create field resolvers | |
| for (const lazyRelation of entityMetadata.lazyRelations) { | |
| let type: any = lazyRelation.type | |
| if (typeof type === 'string') { | |
| type = Primitives[type] | |
| } | |
| // turn type to TypeGraphQL array descriptor if necessary | |
| if (lazyRelation.isManyToOne || lazyRelation.isManyToMany) { | |
| type = [type] | |
| } | |
| // define field resolver property and decorate it | |
| Object.defineProperty( | |
| Output.prototype, | |
| lazyRelation.propertyName, | |
| __decorate( | |
| [FieldResolver(() => type), __param(0, Ctx()), __param(1, Root())], | |
| Output.prototype, | |
| lazyRelation.propertyName, | |
| { | |
| value: async function(context: any, root: any) { | |
| await assertAuth( | |
| auth.canUserReadField, | |
| `Can't read field ${lazyRelation.propertyName}`, | |
| context[tokenContextSelector], | |
| root, | |
| lazyRelation.propertyName, | |
| ) | |
| return root[lazyRelation.propertyName] | |
| }, | |
| }, | |
| ), | |
| ) | |
| } | |
| // create CRUD methods | |
| const inputTypes: { [key: number]: ClassType } = {} | |
| // create mutation | |
| if (options.crud.includes(CRUD.Create)) { | |
| // declare input type | |
| @InputType() | |
| class CreateInput {} | |
| inputTypes[CRUD.Create] = CreateInput | |
| // add columns to input type | |
| for (const ownColumn of entityMetadata.columns) { | |
| // if the field is generated, skip it | |
| if (ownColumn.isGenerated || ownColumn.isCreateDate || ownColumn.isUpdateDate) { | |
| continue | |
| } | |
| // determine field type | |
| let type: any = ownColumn.type | |
| if (typeof type === 'string') { | |
| type = Primitives[type] | |
| } | |
| if (ownColumn.isArray) { | |
| type = [type] | |
| } | |
| // determine field options | |
| const options: any = {} | |
| // if the column has a default value, make it nullable | |
| if (ownColumn.default != null) { | |
| options.nullable = true | |
| } | |
| // if the column is nullable, make it nullable | |
| if (ownColumn.isNullable) { | |
| options.nullable = true | |
| } | |
| // add the property to CreateInput and decorate it | |
| Object.defineProperty( | |
| CreateInput.prototype, | |
| ownColumn.propertyName, | |
| __decorate([Field(() => type, options)], CreateInput.prototype, ownColumn.propertyName, { | |
| writable: true, | |
| }), | |
| ) | |
| } | |
| // add owned relations (ID stand-ins) to input type | |
| for (const ownedRelation of entityMetadata.relationsWithJoinColumns) { | |
| const options: any = {} | |
| // if the column is nullable, make it nullable | |
| if (ownedRelation.isNullable) { | |
| options.nullable = true | |
| } | |
| // add the property to CreateInput and decorate it | |
| Object.defineProperty( | |
| CreateInput.prototype, | |
| `${ownedRelation.propertyName}ID`, | |
| __decorate([Field(() => String, options)], CreateInput.prototype, `${ownedRelation.propertyName}ID`, { | |
| writable: true, | |
| }), | |
| ) | |
| } | |
| // add create method to Output | |
| Object.defineProperty( | |
| Output.prototype, | |
| `create${entityName}`, | |
| __decorate( | |
| [ | |
| Mutation(() => constructor), | |
| __param(0, Ctx()), | |
| __param(1, Arg('input')), | |
| __metadata('design:paramtypes', [undefined, CreateInput]), | |
| ], | |
| Output.prototype, | |
| `create${entityName}`, | |
| { | |
| value: async function(context: any, input: CreateInput) { | |
| await assertAuth( | |
| auth.canUserCreateEntity, | |
| `Can't create entity ${entityName}`, | |
| context[tokenContextSelector], | |
| input, | |
| ) | |
| const entity = entityRepository.create() | |
| // TODO validations here | |
| // handle columns | |
| for (const columns of entityMetadata.columns) { | |
| ;(entity as any)[columns.propertyName] = (input as any)[columns.propertyName] | |
| } | |
| // handle owned relations | |
| for (const ownedRelation of entityMetadata.relationsWithJoinColumns) { | |
| const promise = entityManager | |
| .getRepository(ownedRelation.type) | |
| .findOneOrFail((input as any)[`${ownedRelation.propertyName}ID`]) | |
| ;(entity as any)[ownedRelation.propertyName] = promise | |
| } | |
| // TODO audit here | |
| await entityRepository.save(entity) | |
| return entity | |
| }, | |
| }, | |
| ), | |
| ) | |
| } | |
| // retrieve list query | |
| if (options.crud.includes(CRUD.RetrieveList)) { | |
| Object.defineProperty( | |
| Output.prototype, | |
| `retrieve${entityName}List`, | |
| __decorate([Query(() => [constructor]), __param(0, Ctx())], Output.prototype, `retrieve${entityName}List`, { | |
| value: async function(context: any) { | |
| await assertAuth( | |
| auth.canUserListEntities, | |
| `Can't list entities ${entityName}`, | |
| context[tokenContextSelector], | |
| ) | |
| // TODO pagination, filters, ... | |
| return entityRepository.find() | |
| }, | |
| }), | |
| ) | |
| } | |
| // retrieve by ID query | |
| if (options.crud.includes(CRUD.RetrieveByID)) { | |
| Object.defineProperty( | |
| Output.prototype, | |
| `retrieve${entityName}ByID`, | |
| __decorate( | |
| [ | |
| Query(() => constructor), | |
| __param(0, Ctx()), | |
| __param(1, Arg('id')), | |
| __metadata('design:paramtypes', [undefined, String]), | |
| ], | |
| Output.prototype, | |
| `retrieve${entityName}ByID`, | |
| { | |
| value: async function(context: any, id: string) { | |
| const entity = await entityRepository.findOneOrFail(id) | |
| await assertAuth( | |
| auth.canUserRetrieveEntity, | |
| `Can't retrieve entity ${entityName}`, | |
| context[tokenContextSelector], | |
| entity, | |
| ) | |
| return entity | |
| }, | |
| }, | |
| ), | |
| ) | |
| } | |
| // update by ID mutation | |
| if (options.crud.includes(CRUD.UpdateByID)) { | |
| // declare input type | |
| @InputType() | |
| class UpdateInput {} | |
| inputTypes[CRUD.UpdateByID] = UpdateInput | |
| // add columns to input type | |
| for (const ownColumn of entityMetadata.columns) { | |
| // if the field is generated, skip it | |
| if (ownColumn.isGenerated || ownColumn.isCreateDate || ownColumn.isUpdateDate) { | |
| continue | |
| } | |
| // determine field type | |
| let type: any = ownColumn.type | |
| if (typeof type === 'string') { | |
| type = Primitives[type] | |
| } | |
| if (ownColumn.isArray) { | |
| type = [type] | |
| } | |
| // determine field options | |
| const options = { nullable: true } | |
| // add the property to CreateInput and decorate it | |
| Object.defineProperty( | |
| UpdateInput.prototype, | |
| ownColumn.propertyName, | |
| __decorate([Field(() => type as any, options)], UpdateInput.prototype, ownColumn.propertyName, { | |
| writable: true, | |
| }), | |
| ) | |
| } | |
| // add owned relations (ID stand-ins) to input type | |
| for (const ownedRelation of entityMetadata.relationsWithJoinColumns) { | |
| const options = { nullable: true } | |
| // add the property to the input and decorate it | |
| Object.defineProperty( | |
| UpdateInput.prototype, | |
| `${ownedRelation.propertyName}ID`, | |
| __decorate([Field(() => String, options)], UpdateInput.prototype, `${ownedRelation.propertyName}ID`, { | |
| writable: true, | |
| }), | |
| ) | |
| } | |
| // add update method to Output | |
| Object.defineProperty( | |
| Output.prototype, | |
| `update${entityName}ByID`, | |
| __decorate( | |
| [ | |
| Mutation(() => constructor), | |
| __param(0, Ctx()), | |
| __param(1, Arg('id')), | |
| __param(2, Arg('input')), | |
| __metadata('design:paramtypes', [undefined, String, UpdateInput]), | |
| ], | |
| Output.prototype, | |
| `update${entityName}ByID`, | |
| { | |
| value: async function(context: any, id: string, input: UpdateInput) { | |
| const entity = await entityRepository.findOneOrFail(id) | |
| await assertAuth( | |
| auth.canUserUpdateEntity, | |
| `Can't update ${entityName}`, | |
| context[tokenContextSelector], | |
| entity, | |
| input, | |
| ) | |
| // TODO validations here | |
| // TODO await assert auth - can write here | |
| // handle columns | |
| for (const column of entityMetadata.columns) { | |
| if ((input as any)[column.propertyName] !== undefined) { | |
| ;(entity as any)[column.propertyName] = (input as any)[column.propertyName] | |
| } | |
| } | |
| // handle owned relations | |
| for (const ownedRelation of entityMetadata.relationsWithJoinColumns) { | |
| if ((input as any)[`${ownedRelation.propertyName}ID`] !== undefined) { | |
| ;(entity as any)[`${ownedRelation.propertyName}ID`] = (input as any)[ | |
| `${ownedRelation.propertyName}ID` | |
| ] | |
| } | |
| const promise = entityManager | |
| .getRepository(ownedRelation.type) | |
| .findOneOrFail((input as any)[`${ownedRelation.propertyName}ID`]) | |
| ;(entity as any)[ownedRelation.propertyName] = promise | |
| } | |
| // TODO audit here | |
| await entityRepository.save(entity) | |
| return entity | |
| }, | |
| }, | |
| ), | |
| ) | |
| } | |
| // delete by ID mutation | |
| if (options.crud.includes(CRUD.DeleteByID)) { | |
| let mutation: Function | |
| if (options.softDelete) { | |
| mutation = async function(context: any, id: string) { | |
| const entity: any = await entityRepository.findOneOrFail(id) | |
| await assertAuth( | |
| auth.canUserDeleteEntity, | |
| `Can't delete entity ${entityName}`, | |
| context[tokenContextSelector], | |
| entity, | |
| ) | |
| entity[softDeleteSelector!] = new Date() | |
| await entityRepository.save(entity) | |
| return true | |
| } | |
| } else { | |
| mutation = async function(context: any, id: string) { | |
| const entity = await entityRepository.findOneOrFail(id) | |
| await assertAuth( | |
| auth.canUserDeleteEntity, | |
| `Can't delete entity ${entityName}`, | |
| context[tokenContextSelector], | |
| entity, | |
| ) | |
| await entityRepository.delete(id) | |
| return true | |
| } | |
| } | |
| Object.defineProperty( | |
| Output.prototype, | |
| `delete${entityName}ByID`, | |
| __decorate( | |
| [ | |
| Mutation(() => Boolean), | |
| __param(0, Ctx()), | |
| __param(1, Arg('id')), | |
| __metadata('design:paramtypes', [undefined, String]), | |
| ], | |
| Output.prototype, | |
| `delete${entityName}ByID`, | |
| { | |
| value: mutation, | |
| }, | |
| ), | |
| ) | |
| } | |
| return Output | |
| } | |
| @ObjectType() | |
| @Entity() | |
| class Test { | |
| @Field(() => ID) | |
| @PrimaryGeneratedColumn('uuid') | |
| id!: string | |
| @Field(() => Date) | |
| @CreateDateColumn({ type: 'timestamp without time zone' }) | |
| createdAt!: string | |
| @Field(() => String) | |
| @Column('text') | |
| myField!: string | |
| @Field(() => String, { nullable: true }) | |
| @Column('text', { nullable: true }) | |
| myNullableField!: string | |
| } | |
| async function main() { | |
| try { | |
| const connection = await createConnection({ | |
| type: 'postgres', | |
| username: 'postgres', | |
| password: 'postgres', | |
| database: 'digitaldevmarket', | |
| entities: [Permission, Test], | |
| synchronize: true, | |
| dropSchema: true, | |
| }) | |
| const em = connection.createEntityManager() | |
| // @EntityResolver(Test, {...}) | |
| // @StateMachineResolver(Test, {...}) | |
| @Resolver(() => Test) | |
| class TestResolver extends EntityResolver(em, Test, { | |
| crud: [CRUD.Create, CRUD.RetrieveByID, CRUD.UpdateByID, CRUD.RetrieveList, CRUD.DeleteByID], | |
| softDelete: false, | |
| tokenContextSelector: 'token', | |
| noAuth: true, | |
| }) {} | |
| const schema = await buildSchema({ | |
| resolvers: [TestResolver], | |
| }) | |
| const server = new ApolloServer({ | |
| schema, | |
| playground: true, | |
| }) | |
| // Start the server | |
| const { url } = await server.listen(1337) | |
| console.log('URL', url) | |
| } catch (e) { | |
| console.dir(e) | |
| } | |
| } | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment