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
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)
|
|
}
|
|
}
|