You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

228 lines
7.9 KiB

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>(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>(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>(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>(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>(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<ResourceConfiguration> {
const configs = this.config.get('cobalt.resources') as ResourceConfiguration[]
for ( const config of configs ) {
if ( config.key === key ) {
return config
}
}
}
}