Skip to content

Instantly share code, notes, and snippets.

@rickmak
Created September 4, 2017 06:33
Show Gist options
  • Save rickmak/c6ded242ad149d968f25c6686cc2a533 to your computer and use it in GitHub Desktop.
Save rickmak/c6ded242ad149d968f25c6686cc2a533 to your computer and use it in GitHub Desktop.
Example implementation of restful API
'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