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.

396 lines
14 KiB

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<T> extends Iterable<T> {
public static lift<TInner>(source: Awaitable<Iterable<TInner>>): AwaitableIterable<TInner> {
return new AwaitableIterable<TInner>(source)
}
private resolved?: Iterable<T>
constructor(
protected source: Awaitable<Iterable<T>>
) {
super()
}
at(i: number): Promise<Maybe<T>> {
return this.resolve()
.then(x => x.at(i))
}
clone(): Iterable<T> {
return AwaitableIterable.lift(
this.resolve()
.then(x => x.clone())
)
}
range(start: number, end: number): Promise<Collection<T>> {
return this.resolve()
.then(x => x.range(start, end))
}
count(): Promise<number> {
return this.resolve()
.then(x => x.count())
}
private async resolve(): Promise<Iterable<T>> {
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>(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<QueryRow>
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>(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<QueryRow>
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>(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<QueryRow>
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>(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>(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<ResourceConfiguration> {
const config = await this.getResourceConfig(key)
if ( !config ) {
throw new HTTPError(HTTPStatus.NOT_FOUND)
}
return config
}
protected async getResourceConfig(key: string): Promise<Maybe<ResourceConfiguration>> {
const configs = this.config.get('cobalt.resources') as ResourceConfiguration[]
let config: Maybe<ResourceConfiguration> = 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<QueryRow> {
let iter: Iterable<QueryRow>
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>(Builder)
.from(source.collection)
.limit(500)
.connection(this.db.get())
.getResultIterable()
}
return new AsyncCollection(iter)
}
}