Created
September 4, 2017 06:33
-
-
Save rickmak/c6ded242ad149d968f25c6686cc2a533 to your computer and use it in GitHub Desktop.
Example implementation of restful API
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict'; | |
const _ = require('lodash'); | |
const skygear = require('skygear'); | |
const skygearCloud = require('skygear/cloud'); | |
const { getContainer } = require('./container'); | |
const { | |
fetchSchema, | |
getReferenceType | |
} = require('./schema'); | |
const { toJSON } = require('./record'); | |
const { RecordQuery } = require('./query'); | |
const { embedAssets } = require('./asset'); | |
const { | |
joinsConfig, | |
embedsConfig | |
} = require('./config'); | |
function responseFromError(err) { | |
var statusCode = 500; | |
if (err.code === skygearCloud.ErrorCodes.ResourceNotFound) { | |
statusCode = 404; | |
} | |
return new skygearCloud.SkygearResponse({ | |
statusCode, | |
body: JSON.stringify(err) | |
}); | |
} | |
/** | |
* Return a record from the restful API format. | |
* | |
* Converting object from restful API format to a Record requires schema | |
* because the restful API format lacks type information. | |
*/ | |
function recordFromJSON(recordType, schema, data) { | |
var id = data.id || data._id || undefined; | |
if (id !== undefined) { | |
data.id = recordType + '/' + id; | |
} | |
let Klass = skygear.Record.extend(recordType); | |
let record = new Klass(data); | |
_.each(schema.fields, function (item) { | |
let fieldValue = record[item.name]; | |
if (fieldValue === null) { | |
record[item.name] = null; | |
} else if (fieldValue === undefined) { | |
// do nothing | |
} else if (item.type === 'datetime') { | |
record[item.name] = new Date(record[item.name]); | |
} else if (getReferenceType(item.type)) { | |
let referencedType = getReferenceType(item.type); | |
record[item.name] = new skygear.Reference( | |
referencedType + '/' + record[item.name]); | |
} else if (item.type === 'location') { | |
let lat = record[item.name][0]; | |
let long = record[item.name][1]; | |
record[item.name] = new skygear.Geolocation(lat, long); | |
} else if (item.type === 'asset') { | |
const url = record[item.name].url; | |
const name = record[item.name].id; | |
record[item.name] = new skygear.Asset({ | |
name: name, | |
url: url | |
}); | |
} | |
}); | |
return record; | |
} | |
/** | |
* Return an object for restful API from a Record. | |
*/ | |
function recordToJSON(record) { | |
// Metadata fields | |
/* eslint-disable camelcase */ | |
let payload = { | |
_id: skygear.Record.parseID(record.id)[1], | |
_access: record.access.toJSON(), | |
_created_at: toJSON(record.createdAt), | |
_updated_at: toJSON(record.updatedAt), | |
_created_by: record.createdBy, | |
_updated_by: record.updatedBy, | |
_ownerID: record.ownerID | |
}; | |
/* eslint-enable camelcase */ | |
// Developer-defined fields | |
_.each(record.attributeKeys, function (key) { | |
payload[key] = toJSON(record[key]); | |
}, record); | |
return payload; | |
} | |
/** | |
* RestfulRecord is a high-level restful handler for handling CRUD | |
* record requests. | |
* | |
* The restful API has a different payload format and query parameters | |
* that are different from Skygear Record format. This RestfulRecord | |
* handles the conversion between the two formats when saving and fetching. | |
* | |
* RestfulRecord can also be configured to make joined queries so | |
* that remote records that are referencing the local records can | |
* be included in the payload as an array of IDs or embedded objects. | |
* | |
* When saving, the references in the remote records are also updated as | |
* the array of IDs or embedded objects are updated. | |
*/ | |
class RestfulRecord { | |
/** | |
* Creates an instance of RestfulRecord. RestfulRecord is expected | |
* to be used to serve a single request. | |
* | |
* @param { string } recordType - the record type that the API will fetch and | |
* save, also refer to as local record type | |
* @param { Object } schemas - Skygear Record schemas of all record types | |
* @param { Object } joining - configuration for joining remote record types | |
*/ | |
constructor(recordType, schemas = null, joining = {}, embedding = {}) { | |
this.recordType = recordType; | |
this.schemas = schemas; | |
this.joining = joining || {}; | |
this.embedding = embedding; | |
// TODO: context should be part of the constructor parameter. | |
// Now this is exposed as a property. | |
this.context = {}; | |
} | |
/** | |
* Obtain an object of Skygear Container for making Record changes. | |
* | |
* This property requires the context to be configured with user info. | |
*/ | |
get container() { | |
return getContainer(this.context.user_id); | |
} | |
/** | |
* Obtain an object of Skygear Database where changes will be made. | |
*/ | |
get database() { | |
// TODO: should support making changes to private database. | |
// Now private database records cannot be altered or created. | |
return new skygear.Database('_public', this.container); | |
} | |
/** | |
* Make a SQL query for records. | |
* | |
* The returned records include joined columns which contains Record IDs | |
* of the remote records. | |
* | |
* @return promise of records | |
*/ | |
makeQuery(client, attrs = {}, limit = 100, offset = 0, sortBy = {}) { | |
let query = new RecordQuery( | |
this.recordType, | |
this.schemas[this.recordType], | |
this.joining | |
); | |
return query.query(client, attrs, limit, offset, sortBy); | |
} | |
/** | |
* Make a SQL count query for records. | |
* | |
* @return promise of count | |
*/ | |
makeCountQuery(client, attrs = {}) { | |
let query = new RecordQuery( | |
this.recordType, | |
this.schemas[this.recordType], | |
this.joining | |
); | |
return query.count(client, attrs); | |
} | |
/** | |
* Make multiple SQL queries for remote records that references the | |
* local records. | |
* | |
* For joined fields, the Record IDs in the array in the specified | |
* records are used to query for remote records. The result can be | |
* used to replace the array of Record IDs with an array of embedded | |
* objects. | |
* | |
* @return promise of remote records by record ID | |
*/ | |
makeSubQueries(client, records) { | |
let schemas = this.schemas; | |
var promises = []; | |
_.each(this.embedding, function (value, key) { | |
let ids = []; | |
// Get all subquery IDs from all records that requires embedding. | |
if (value.hasOne) { | |
ids = _.map(records, key); | |
} else { | |
ids = [].concat.apply([], _.map(records, key)); | |
} | |
if (ids.length === 0) { | |
return; | |
} | |
let subQuery = new RecordQuery( | |
value.recordType, | |
schemas[value.recordType] | |
); | |
promises.push(subQuery.query(client, {_id: ids})); | |
}); | |
// Resolve a promise early if there are no queries to make. | |
if (promises.length === 0) { | |
return Promise.resolve({}); | |
} | |
// Resolve promise with a mapping of record ID and the remote records. | |
return Promise.all(promises) | |
.then((results) => { | |
let subQueryRecords = [].concat.apply([], results); | |
let byId = _.reduce(subQueryRecords, (result, record) => { | |
result[record.id] = record; | |
return result; | |
}, {}); | |
return byId; | |
}); | |
} | |
/** | |
* Return JSON-encoded object of a record with remote records embedded. | |
* | |
* If the record is not involved in a join configuration, record | |
* will be converted to restful API format without embeddeding. | |
* | |
* Embedding can be a single record or an array of record. | |
*/ | |
embeddedRecordToJSON(record, remoteRecordsByID) { | |
let payload = recordToJSON(record); | |
_.each(this.embedding, function (value, key) { | |
if (value.hasOne) { | |
let id = payload[key]; | |
let remoteRecord = remoteRecordsByID[value.recordType + '/' + id]; | |
if (remoteRecord !== null && remoteRecord !== undefined) { | |
payload[key] = recordToJSON(remoteRecord); | |
} | |
} else { | |
var remoteRecords = []; | |
_.each(payload[key], function (id) { | |
let remoteRecord = remoteRecordsByID[value.recordType + '/' + id]; | |
if (remoteRecord !== null && remoteRecord !== undefined) { | |
remoteRecords.push(recordToJSON(remoteRecord)); | |
} | |
}); | |
payload[key] = remoteRecords; | |
} | |
}); | |
return payload; | |
} | |
/** | |
* Fetch records and remote records. | |
* | |
* This operation is suitable for fetching all records and remote | |
* records required to return records to the client. This can be | |
* useful for all read and saving operations because all of these | |
* operations return record data in the response body. | |
* | |
* If there is no join configuration that requires embedded, no remote | |
* records are fetched. | |
*/ | |
fetchRecords(client, attrs, limit, offset, sortBy) { | |
return this.makeQuery(client, attrs, limit, offset, sortBy) | |
.then((records) => { | |
return Promise.all([ | |
Promise.resolve(records), | |
this.makeSubQueries(client, records) | |
]); | |
}) | |
.then((results) => { | |
return _.map(results[0], function (record) { | |
return this.embeddedRecordToJSON( | |
record, | |
results[1] | |
); | |
}.bind(this)); | |
}) | |
.then((records)=> { | |
// Here we try to fetch assets and embed them in JSON. | |
// Because skygear explicitly blocks fetching the `_asset` table, | |
// we have to workaround using sql | |
const fields = this.schemas[this.recordType].fields; | |
return embedAssets(client, fields, records); | |
}); | |
} | |
/** | |
* index route for listing the entity | |
* | |
* index route accept the following query parameter: | |
* | |
* `_perPage` - How many item to return in a query, default 10 | |
* `_page` - Which page to return | |
* `_sortDir` - Sort direction, `ASC` or `DESC` | |
* `_sortField` - Which filed to sort | |
* `_filters` - Filter the entity. URL-encoded JSON object in query string. | |
* | |
* `_filters` example: | |
* | |
* ``` | |
* { | |
* "group": "admin" | |
* } | |
* ``` | |
* The above filter will only return the record have `group` that have value | |
* `admin`. If `group` is array in DB, the filter will return record | |
* contains item 'admin'. i.e. `['admin']` and `['admin', 'user']` will | |
* match. | |
* | |
* For filtering string contains specific substring, use the `_q` filter as | |
* follow | |
* ``` | |
* { | |
* "_q": { | |
* "email": "skygeario" | |
* } | |
* } | |
* ``` | |
* The above filter will return all records which email contains the string | |
* `skygeario`. | |
* | |
* For filtering with specific operators as follows | |
* ``` | |
* { | |
* "rating": { | |
* "gt": 7 | |
* } | |
* } | |
* ``` | |
* The above filter will retrun all records which rating values are greater | |
* than 7. | |
*/ | |
index(req) { // eslint-disable-line no-unused-vars | |
return new Promise((resolve, reject) => { | |
skygearCloud.poolConnect(function (err, client, done) { | |
if (err !== null && err !== undefined) { | |
reject(err); | |
return; | |
} | |
let limit = 30; | |
let offset = 0; | |
const { | |
_page, | |
_perPage, | |
_sortDir, | |
_sortField, | |
_filters | |
} = req.query; | |
const perPage = parseInt(_perPage, 10); | |
if (perPage) { | |
limit = perPage; | |
} | |
const page = parseInt(_page, 10); | |
if (page && perPage) { | |
offset = (page - 1) * perPage; | |
} | |
const sortBy = {}; | |
if (_sortDir && _sortField) { | |
sortBy[_sortField] = _sortDir; | |
} | |
let filters = {}; | |
if (_filters !== undefined && _filters !== null) { | |
try { | |
filters = JSON.parse(_filters); | |
} catch (e) { | |
console.log(`Unable to parse the filters: ${_filters}`); | |
} | |
} | |
return Promise.all([ | |
this.fetchRecords(client, filters, limit, offset, sortBy), | |
this.makeCountQuery(client, {}) | |
]) | |
.then((result) => { | |
done(); | |
var data = { | |
result: result[0], | |
info: { | |
count: result[1] | |
} | |
}; | |
resolve(data); | |
}, (queryError) => { | |
done(); | |
reject(queryError); | |
}); | |
}.bind(this)); | |
}); | |
} | |
get(req, id) { | |
return new Promise((resolve, reject) => { | |
skygearCloud.poolConnect(function (err, client, done) { | |
if (err !== null && err !== undefined) { | |
reject(err); | |
return; | |
} | |
return this.fetchRecords(client, {_id: id}, 1) | |
.then((records) => { | |
done(); | |
if (records.length === 0) { | |
resolve(responseFromError(new skygearCloud.SkygearError( | |
'record not found', | |
skygearCloud.ErrorCodes.ResourceNotFound | |
))); | |
return; | |
} | |
resolve(records[0]); | |
}, (queryError) => { | |
done(); | |
reject(queryError); | |
}); | |
}.bind(this)); | |
}); | |
} | |
/** | |
* Return an array of remote records to be saved to accomplish | |
* the specified changes. | |
* | |
* For embedding fields, the embedded fields will be included | |
* in the records to be saved. | |
* | |
* For each join fields, the original and the updated objects are | |
* compared to find all remote records to be updated. | |
*/ | |
remoteRecordsToSave(attrs, original = {}) { | |
let schemas = this.schemas; | |
// TODO: multiple columns maybe updating the same record | |
// FIXME: this is not working if records are in private database. | |
var records = []; | |
_.each(this.embedding, function (value, key) { | |
let schema = schemas[value.recordType]; | |
if (value.hasOne) { | |
let remoteRecord = attrs[key]; | |
if (!remoteRecord) { | |
return; | |
} | |
records.push(recordFromJSON(value.recordType, schema, remoteRecord)); | |
} else { | |
records = _.union( | |
records, | |
this.remoteRecordFromEmbeddedListToSave( | |
value, key, schema, attrs, original) | |
); | |
} | |
}.bind(this)); | |
_.each(this.joining, function (value, key) { | |
let schema = schemas[value.recordType]; | |
if (this.embedding[key]) { | |
// console.log('embedding already handled', key); | |
return; | |
} | |
records = _.union( | |
records, | |
this.remoteRecordFromReferenceManyToSave( | |
value, key, schema, attrs, original) | |
); | |
}.bind(this)); | |
return records; | |
} | |
/** | |
* remoteRecordFromEmbeddedListToSave extract the array of remote records | |
* from the record attrs. The extracted records is use to presisted | |
* attributes changes. | |
*/ | |
remoteRecordFromEmbeddedListToSave(value, key, schema, attrs, original = {}) { | |
const records = []; | |
let attrIDs = _(attrs[key]).map('_id').filter(function (id) { | |
return id !== undefined && id !== null; | |
}).value(); | |
let originalIDs = original[key]; | |
let removing = _.difference(originalIDs, attrIDs); | |
// Clear the foreign key to remove references | |
_.each(removing, function (id) { | |
console.log('Removing reference on ID:', id); | |
let remoteRecord = { id }; | |
remoteRecord[value.foreignKey] = null; | |
records.push(recordFromJSON(value.recordType, schema, remoteRecord)); | |
}); | |
// Update all embedded records | |
_.each(attrs[key], function (remoteRecord) { | |
console.log('Adding reference on embedded record:', remoteRecord); | |
remoteRecord[value.foreignKey] = attrs.id; | |
records.push(recordFromJSON(value.recordType, schema, remoteRecord)); | |
}); | |
return records; | |
} | |
/** | |
* remoteRecordFromReferenceManyToSave extract the array of remote records | |
* with foreignKey updated. Reference many type will not have attributes | |
* update. | |
*/ | |
remoteRecordFromReferenceManyToSave( | |
value, key, schema, attrs, original = {} | |
) { | |
const records = []; | |
let attrIDs = attrs[key]; | |
let originalIDs = original[key]; | |
let adding = _.difference(attrIDs, originalIDs); | |
let removing = _.difference(originalIDs, attrIDs); | |
_.each(removing, function (id) { | |
let remoteRecord = { id }; | |
remoteRecord[value.foreignKey] = null; | |
records.push(recordFromJSON(value.recordType, schema, remoteRecord)); | |
}); | |
_.each(adding, function (id) { | |
let remoteRecord = { id }; | |
remoteRecord[value.foreignKey] = attrs.id; | |
records.push(recordFromJSON(value.recordType, schema, remoteRecord)); | |
}); | |
return records; | |
} | |
/** | |
* Save records. | |
* | |
* This operation is suitable for saving all records and remote | |
* records required to make changes requested by the client. | |
*/ | |
saveRecord(attrs) { | |
let recordName = attrs.id; | |
return new Promise((resolve, reject) => { | |
skygearCloud.poolConnect(function (err, client, done) { | |
if (err !== null && err !== undefined) { | |
reject(err); | |
return; | |
} | |
this.makeQuery(client, { _id: recordName }, 1) | |
.then((records) => { | |
done(); | |
if (records.length === 0) { | |
return Promise.resolve(null); | |
} | |
return Promise.resolve(records[0]); | |
}, (queryError) => { | |
done(); | |
return Promise.reject(queryError); | |
}) | |
.then((record) => { | |
let recordsToSave = this.remoteRecordsToSave(attrs, record); | |
_.each(this.embedding, function (value, key) { | |
delete attrs[key]; | |
}); | |
_.each(this.joining, function (value, key) { | |
delete attrs[key]; | |
}); | |
recordsToSave.push(recordFromJSON( | |
this.recordType, | |
this.schemas[this.recordType], | |
attrs | |
)); | |
resolve(recordsToSave); | |
}, (queryError) => { | |
reject(queryError); | |
}); | |
}.bind(this)); | |
}) | |
.then((recordsToSave) => { | |
console.log(recordsToSave); | |
return this.database.save(recordsToSave); | |
}) | |
.then(() => { | |
return new Promise((resolve, reject) => { | |
skygearCloud.poolConnect(function (err, client, done) { | |
if (err !== null && err !== undefined) { | |
reject(err); | |
return; | |
} | |
return this.fetchRecords(client, {_id: recordName}, 1) | |
.then((records) => { | |
done(); | |
resolve(records[0]); | |
}, (queryError) => { | |
done(); | |
reject(queryError); | |
}); | |
}.bind(this)); | |
}); | |
}); | |
} | |
create(req) { | |
var attrs = req.json; | |
var localAttributes = _.clone(attrs); | |
_.each(this.joining, function (value, key) { | |
delete localAttributes[key]; | |
}); | |
return this.saveRecord(attrs); | |
} | |
update(req, id) { | |
var attrs = req.json; | |
attrs.id = id; | |
return this.saveRecord(attrs); | |
} | |
delete(req, id) { | |
let record = { id: this.recordType + '/' + id }; | |
return this.database.delete(record) | |
.then(() => { | |
return new skygearCloud.SkygearResponse({ | |
statusCode: 204, | |
body: JSON.stringify({}) | |
}); | |
}, (err) => { | |
return responseFromError(err); | |
}); | |
} | |
} | |
class RestfulAPI { | |
constructor(ngAdmin = null, defaultResourceClass = RestfulRecord) { | |
this.ngAdmin = ngAdmin; | |
this.defaultResourceClass = defaultResourceClass; | |
this.resources = {}; | |
this.schemas = null; | |
this.joining = {}; | |
} | |
addResourceClass(recordType, resource) { | |
this.resources[recordType] = resource; | |
} | |
createResourceObject(recordType) { | |
return this.fetchSchema().then(function (schema) { | |
let recordSchema = schema[recordType]; | |
let recordJoins = this.joining[recordType] || {}; | |
let recordEmbed = this.embedding[recordType] || {}; | |
var Klass = this.resources[recordType]; | |
if ( | |
(recordSchema === null || recordSchema === undefined) && | |
(Klass === null || Klass === undefined) | |
) { | |
return Promise.reject(new skygearCloud.SkygearError( | |
'record not found', | |
skygearCloud.ErrorCodes.ResourceNotFound | |
)); | |
} | |
if (Klass === null || Klass === undefined) { | |
Klass = this.defaultResourceClass; | |
} | |
// Create the resource object. We pass the entire | |
// record schema so to support record joins. | |
return new Klass(recordType, schema, recordJoins, recordEmbed); | |
}.bind(this)); | |
} | |
invalidateSchemaCache() { | |
this.schemas = null; | |
} | |
fetchSchema() { | |
return new Promise((resolve) => { | |
if (this.schemas !== null) { | |
resolve(this.schemas.record_types); | |
return; | |
} | |
return fetchSchema().then(function (result) { | |
this.schemas = result; | |
// If ngAdmin exists, use ngAdmin to generate joins configuration. | |
// Otherwise use blank configuration. | |
if (this.ngAdmin !== null && this.ngAdmin !== undefined) { | |
this.joining = joinsConfig(this.ngAdmin, this.schemas.record_types); | |
console.log('joins config:', this.joining); | |
this.embedding = embedsConfig( | |
this.ngAdmin, this.schemas.record_types); | |
console.log('embedding config:', this.embedding); | |
} else { | |
this.joining = {}; | |
this.embedding = {}; | |
} | |
resolve(this.schemas.record_types); | |
}.bind(this)); | |
}); | |
} | |
index(req, context, recordType) { | |
return this.createResourceObject(recordType) | |
.then((resource) => { | |
resource.context = context; | |
return resource.index(req); | |
}); | |
} | |
get(req, context, recordType, id) { | |
return this.createResourceObject(recordType) | |
.then((resource) => { | |
resource.context = context; | |
return resource.get(req, id); | |
}); | |
} | |
create(req, context, recordType) { | |
return this.createResourceObject(recordType) | |
.then((resource) => { | |
resource.context = context; | |
return resource.create(req); | |
}); | |
} | |
update(req, context, recordType, id) { | |
return this.createResourceObject(recordType) | |
.then((resource) => { | |
resource.context = context; | |
return resource.update(req, id); | |
}); | |
} | |
delete(req, context, recordtype, id) { | |
return this.createResourceObject(recordtype) | |
.then((resource) => { | |
resource.context = context; | |
return resource.delete(req, id); | |
}); | |
} | |
} | |
module.exports = { | |
RestfulRecord, | |
RestfulAPI | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment