import { api, Builder, collect, Config, Controller, DatabaseService, DataContainer, hasOwnProperty, HTTPError, HTTPStatus, Inject, Injectable, Maybe, QueryRow, } from '@extollo/lib' import {FieldDefinition, FieldType, ResourceAction, ResourceConfiguration} from '../../../cobalt' const parser = require('any-date-parser') @Injectable() export class ResourceAPI extends Controller { @Inject() protected readonly config!: Config @Inject() protected readonly db!: DatabaseService public configure(key: string) { const config = this.getResourceConfig(key) if ( config ) { return api.one(config) } throw new HTTPError(HTTPStatus.NOT_FOUND) } public async read(key: string) { const config = this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.read) const result = await this.make(Builder) .select(config.primaryKey, ...config.fields.map(x => x.key)) .from(config.collection) .connection(this.db.get()) .get() .all() return api.many(result) } public async readOne(key: string, id: number|string) { const config = this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.readOne) const row = await this.make(Builder) .select(config.primaryKey, ...config.fields.map(x => x.key)) .from(config.collection) .where(config.primaryKey, '=', id) .limit(1) .connection(this.db.get()) .first() if ( !row ) { throw new HTTPError(HTTPStatus.NOT_FOUND) } return api.one(row) } public async create(key: string, dataContainer: DataContainer) { const config = this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.create) // Load input values const 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) } // Create insert query const result = await this.make(Builder) .table(config.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 = 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 const result = await this.make(Builder) .table(config.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 = this.getResourceConfigOrFail(key) this.checkAction(config, ResourceAction.delete) // Make sure the row exists await this.readOne(key, id) // Execute the query await this.make(Builder) .table(config.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 ) { 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) if ( options.pluck('value').includes(value) ) { return value } if ( required ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid or missing value for select: ${key}`) } } } protected getResourceConfigOrFail(key: string): ResourceConfiguration { const config = this.getResourceConfig(key) if ( !config ) { throw new HTTPError(HTTPStatus.NOT_FOUND) } return config } protected getResourceConfig(key: string): Maybe { const configs = this.config.get('cobalt.resources') as ResourceConfiguration[] for ( const config of configs ) { if ( config.key === key ) { return config } } } }