import { api, ArrayIterable, AsyncCollection, AsyncPipe, Awaitable, Builder, collect, Collection, Config, Controller, DatabaseService, DataContainer, hasOwnProperty, HTTPError, HTTPStatus, Inject, Injectable, Iterable, Maybe, QueryRow, } from '@extollo/lib' import { DataSource, FieldDefinition, FieldType, ResourceAction, ResourceConfiguration, SelectOptions, } from '../../../cobalt' const parser = require('any-date-parser') class AwaitableIterable extends Iterable { public static lift(source: Awaitable>): AwaitableIterable { return new AwaitableIterable(source) } private resolved?: Iterable constructor( protected source: Awaitable> ) { super() } at(i: number): Promise> { return this.resolve() .then(x => x.at(i)) } clone(): Iterable { return AwaitableIterable.lift( this.resolve() .then(x => x.clone()) ) } range(start: number, end: number): Promise> { return this.resolve() .then(x => x.range(start, end)) } count(): Promise { return this.resolve() .then(x => x.count()) } private async resolve(): Promise> { if ( !this.resolved ) { this.resolved = await this.source } return this.resolved } } @Injectable() export class ResourceAPI extends Controller { @Inject() protected readonly config!: Config @Inject() protected readonly db!: DatabaseService public async configure(key: string) { const config = await this.getResourceConfig(key) if ( config ) { return api.one(config) } throw new HTTPError(HTTPStatus.NOT_FOUND) } public async read(key: string) { const config = await this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.read) let result: QueryRow[] if ( hasOwnProperty(config.source, 'controller') ) { const controller = config.source.controller.apply(this.request) result = await controller.read() } else if ( hasOwnProperty(config.source, 'method') ) { const method = config.source.method.apply(this.request) result = await method() } else { result = await this.make(Builder) .select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key)) .from(config.source.collection) .limit(500) .orderBy(config.orderField || config.primaryKey, config.orderDirection || 'asc') .connection(this.db.get()) .get() .all() } if ( config.processAfterRead ) { result = await Promise.all(result.map(config.processAfterRead)) } return api.many(result) } public async readOne(key: string, id: number|string) { const config = await this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.readOne) let row: Maybe if ( hasOwnProperty(config.source, 'controller') ) { const controller = config.source.controller.apply(this.request) row = await controller.readOne(id) } else if ( hasOwnProperty(config.source, 'method') ) { const method = config.source.method.apply(this.request) row = collect(await method()) .firstWhere(config.primaryKey, '=', key) } else { row = await this.make(Builder) .select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key)) .from(config.source.collection) .where(config.primaryKey, '=', id) .limit(1) .connection(this.db.get()) .first() } if ( !row ) { throw new HTTPError(HTTPStatus.NOT_FOUND) } if ( config.processAfterRead ) { row = await config.processAfterRead(row) } return api.one(row) } public async create(key: string, dataContainer: DataContainer) { const config = await this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.create) // Load input values let queryRow: QueryRow = {} for ( const field of config.fields ) { const value = dataContainer.input(field.key) if ( field.required && typeof value === 'undefined' ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${field.key}`) } queryRow[field.key] = this.castValue(field, value) } if ( config.generateKeyOnInsert ) { queryRow[config.primaryKey] = config.generateKeyOnInsert() } if ( config.processBeforeInsert ) { queryRow = await config.processBeforeInsert(queryRow) } // Create insert query let result: Maybe if ( hasOwnProperty(config.source, 'controller') ) { const controller = config.source.controller.apply(this.request) result = await controller.insert(queryRow) } else if ( hasOwnProperty(config.source, 'method') ) { throw new Error('The "method" source type does not support creating records.') } else { result = await this.make(Builder) .table(config.source.collection) .returning(config.primaryKey, ...config.fields.map(x => x.key)) .connection(this.db.get()) .insert(queryRow) .then(x => x.rows.first()) } // Return result return api.one(result) } public async update(key: string, id: number|string, dataContainer: DataContainer) { const config = await this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.update) // Load input values const queryRow: QueryRow = { [config.primaryKey]: id } for ( const field of config.fields ) { const value = dataContainer.input(field.key) if ( field.required && typeof value === 'undefined' ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${field.key}`) } queryRow[field.key] = this.castValue(field, value) } // Make sure the record already exists await this.readOne(key, id) // Create update query let result: Maybe if ( hasOwnProperty(config.source, 'controller') ) { const controller = config.source.controller.apply(this.request) result = await controller.update(id, queryRow) } else if ( hasOwnProperty(config.source, 'method') ) { throw new Error('The "method" source type does not support updating records.') } else { result = await this.make(Builder) .table(config.source.collection) .returning(config.primaryKey, ...config.fields.map(x => x.key)) .connection(this.db.get()) .where(config.primaryKey, '=', id) .update(queryRow) .then(x => x.rows.first()) } // Return the result return api.one(result) } public async delete(key: string, id: number|string) { const config = await this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.delete) // Make sure the row exists await this.readOne(key, id) // Execute the query if ( hasOwnProperty(config.source, 'controller') ) { const controller = config.source.controller.apply(this.request) await controller.delete(id) } else if ( hasOwnProperty(config.source, 'method') ) { throw new Error('The "method" source type does not support deleting records.') } else { await this.make(Builder) .table(config.source.collection) .connection(this.db.get()) .where(config.primaryKey, '=', id) .delete() } return { success: true } } private checkAction(config: ResourceConfiguration, action: ResourceAction) { if ( !config.supportedActions.includes(action) ) { throw new HTTPError(HTTPStatus.METHOD_NOT_ALLOWED, `Unsupported action: ${action}`) } } private castValue(fieldDef: FieldDefinition, value: any): any { const { type, key, required } = fieldDef if ( type === FieldType.text || type === FieldType.textarea || type === FieldType.html ) { const cast = String(value || '') if ( required && !cast ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`) } return cast } else if ( type === FieldType.number ) { const cast = parseFloat(String(value)) if ( required && !value && value !== 0 ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`) } if ( isNaN(cast) ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Field must be a number: ${key}`) } return cast } else if ( type === FieldType.integer ) { const cast = parseInt(String(value)) if ( required && !value && value !== 0 ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`) } if ( isNaN(cast) ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Field must be a number: ${key}`) } return cast } else if ( type === FieldType.date ) { if ( required && !value ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`) } else if ( !value ) { return } return parser.fromAny(value) } else if ( type === FieldType.email ) { const cast = String(value || '') if ( required && !cast ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`) } const rex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ if ( !rex.test(cast) ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid e-mail address format: ${key}`) } return cast } else if ( type === FieldType.select && hasOwnProperty(fieldDef, 'options') ) { const options = collect(fieldDef.options as SelectOptions) if ( options.pluck('value').includes(value) ) { return value } if ( required ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid or missing value for select: ${key}`) } } else if ( type === FieldType.bool ) { return !!value } } protected async getResourceConfigOrFail(key: string): Promise { const config = await this.getResourceConfig(key) if ( !config ) { throw new HTTPError(HTTPStatus.NOT_FOUND) } return config } protected async getResourceConfig(key: string): Promise> { const configs = this.config.get('cobalt.resources') as ResourceConfiguration[] let config: Maybe = undefined for ( const match of configs ) { if ( match.key === key ) { config = match break } } if ( !config ) { return config } // Resolve `select` fields with remote sources config.fields = await collect(config.fields) .map(async field => { if ( field.type === FieldType.select && hasOwnProperty(field, 'source') ) { return { ...field, options: await this.readSource(field.source) .map(qr => ({ display: qr[field.displayFrom], value: qr[field.valueFrom], })) .then(c => c.all()) } as FieldDefinition } return field }) .awaitAll() .then(x => x.all()) return config } protected readSource(source: DataSource): AsyncCollection { let iter: Iterable if ( hasOwnProperty(source, 'controller') ) { iter = AwaitableIterable.lift( AsyncPipe.wrap(source.controller.apply(this.request)) .tap(controller => controller.read()) .tap(rows => new ArrayIterable(rows)) .resolve() ) } else if ( hasOwnProperty(source, 'method') ) { iter = AwaitableIterable.lift( AsyncPipe.wrap(source.method.apply(this.request)) .tap(method => method()) .tap(rows => new ArrayIterable(rows)) .resolve() ) } else { iter = this.make(Builder) .from(source.collection) .limit(500) .connection(this.db.get()) .getResultIterable() } return new AsyncCollection(iter) } }