RESTe
FUTURE BREAKING CHANGE
In 1.4.5, a new option to support error objects is available. It's off by default and can be switched on by setting:
.errorsAsObjects = true
in the RESTe config. This will ensure you get full objects back for errors so you can access status codes, the http object itself. For legacy support this is off by default but will be the default in future versions so please, update your apps!
Important note on JSON data
RESTe tries to make sense of the data that comes back but currently it will have problems with invalid JSON data. If you're having any issues with data not being rendered / bound, check it's valid JSON so everything is wrapped as a string. JSON has real issues with numbers -- if it's a normal number it's fine but putting in say 000000 for a property can cause issues in parsing.
Also, make sure you're not using the app/models folder or the <Collection / Model src="etc"/> tags -- you only need to define your models in the RESTe config, and then use the dataCollection property in the repeater.
Why?
I build a lot of apps that integrate with APIs. These could be written using the open-source Parse Server or a hosted service, but more often they are custom APIs written by another developer. I used to use a basic api.js library to handle the API integration, but this typically involved writing my own module for the API in question, requiring the api.js module, and writing specific methods for the app.
The Old Way
So previously I'd end up writing methods like this:
exports.getPreviousLocations = function(callback) {
var Rest = new Api(Alloy.CFG.baseURL + "users/" + token + "/previouslocations");
Rest.get(function(e) {
processResponse(e, function() {
callback(e.result);
});
});
};
or a POST one like this:
exports.updateUser = function(name, email, password, callback) {
var Rest = new Api(Alloy.CFG.baseURL + "users/" + token);
Rest.post(JSON.stringify({
"name" : name,
"email" : email,
"password" : password
}), function(e) {
processResponse(e, function() {
callback(e);
});
});
};
(The processResponse function was written to try to parse the data as it came back, check for success / results etc - but even with that I was finding myself duplicating a lot of code.)
A New Way - Using RESTe
The idea behind RESTe was to have a single JS library I could drop in a project, apply a simple config, and have it generate the methods for me.
The main things I wanted to achieve were:-
- Simple to implement in an new project, or replace an existing API layer
- Supports headers, tokens, events
- Support for Alloy Collections / Models
- Minimal code
Quick Start
(If your Titanium app does not use node modules already, follow the guide here )
Using NPM Packages in Titanium
- Install from NPM the latest version within your app/lib folder or
- Download the latest version and place in your project (lib folder for Alloy).
Ideally you should put your RESTe config in alloy.js OR in an app/lib file that is called from alloy.js or before you intend to use any colletions / models:-
var reste = require("reste");
var api = new reste();
// now we can do our one-time configure
api.config({
debug: true, // allows logging to console of ::REST:: messages
errorsAsObjects: true, // Default: false. New in 1.4.5, will break 1.4.4 apps that handle errors
autoValidateParams: false, // set to true to throw errors if <param> url properties are not passed
validatesSecureCertificate: false, // Optional: If not specified, default behaviour from http://goo.gl/sJvxzS is kept.
timeout: 4000,
url: "https://api.parse.com/1/",
requestHeaders: {
"X-Parse-Application-Id": "APPID",
"X-Parse-REST-API-Key": "RESTID",
"Content-Type": "application/json"
},
methods: [{
name: "courses",
post: "functions/getCourses",
onError: function(e, callback, globalOnError){
alert("There was an error getting the courses!");
}
}, {
name: "getVideos",
get: "classes/videos"
}, {
name: "getVideoById",
get: "classes/videos/<videoId>"
}, {
name: "addVideo",
post: "classes/videos"
}],
onError: function(e, retry) {
var dialog = Ti.UI.createAlertDialog({
title: "Connection error",
message: "There was an error connecting to the server, check your network connection and retry.",
buttonNames: ['Retry']
});
dialog.addEventListener("click", function() {
retry();
});
dialog.show();
},
onLoad: function(e, callback) {
callback(e);
}
});
IMPORTANT: You can't put the config in the same file as a controller that is binding with Alloy. This is because Alloy will attempt to resolve any references for dataCollection before the config is ready -- so for best results put the config into alloy.js directly OR require it from a lib/whatever.js file from alloy.js.
Hooks: beforePost, beforeSend
A couple of useful hooks or events can be used within your RESTe global configuration. Those hooks will happen before specific calls are made. They will be executed before any request is sent allowing you to a) change the parameters or b) stop the call happening.
beforePost:
This one is quite useful if you need to change the parameters which are going to be used for the request. You might for example -- if you're using Parse Server -- want to strip out certain parameters from models before sending them.
Example:
{
...
beforePost: function(params, callback) {
params.something = 'else';
callback(params);
},
...
}
beforeSend:
These are similar to beforeSend but works for all requests (GET, PUT, DELETE, POST). If you specify both beforePost and beforeLoad then beforePost will go first, then beforeSend.
Example:
{
...
beforeSend: function(data, callback) {
if (Ti.Network.online) {
callback(data);
} else {
alert("No internet connection!");
}
},
...
}
Errors
By default RESTe returns the body of the response from the API when an error occurs using responseText
from the HTTP Client.
You can change this by specifying errorsAsObjects: true
within your RESTe config so you get the full error object back. This will include the error object as well as the body response from the API which will be accessible from the content
property on the object returned.
Example of a the object returned including the error and the response body:
{
"success": false,
"code": 401,
"content": {
"error": "invalid_credentials",
"message": "The user credentials were incorrect."
},
"source": "[object TiNetworkHTTPClient]",
"type": "error",
"error": "HTTP error",
"url": "http://lorem.ipsum.com"
}
This is really useful if you need to access both the HTTP status code as well as the potential error message returned from the API.
onError() and onLoad()
You can pass the optional onError and onLoad handlers, which will intercept the error or retrieved data before it's passed to the calling function's callback. This way you can change, test, do-what-you-want-with-it before passing it on.
Note, in the onError handler, you can (as of 1.2.0) also handle any network errors better -- in the example above a retry method is returned so you can check the error, display a specific one, or handle any network issues, and if required, issue a retry() which will attempt the last call again.
By default, a local error handler will override the global error handler. So if you want to use both (so have local fire first and then pass off to global, then handle this within your own app).
In this example we have a function in the same file as the RESTe config like this:
function globalError(e, retry) {
var dialog = Ti.UI.createAlertDialog({
title: "Connection error",
message: "There was an error connecting to the server, check your network connection and retry.",
buttonNames: ['Retry']
});
dialog.addEventListener("click", function() {
retry();
});
dialog.show();
}
and in our RESTe config we have:
methods: [{
name: "courses",
post: "functions/getCourses",
onError: function(e, callback) {
if (e.message) {
alert(e.message);
} else {
globalError(e, callback);
}
}, {
...
}],
onError: globalError,
If you specify parameters required e.g. videoId then RESTe will automatically check for these in the parameters passed to the method, and raise an error if they're missing.
Once you've done all this (and assuming no errors), you'll have new methods available:
api.getVideos(function(videos) {
// do stuff with the videos here
});
Or call a method with a specific Id:
api.getVideoById({
videoId: "fUAM4ZFj9X"
}, function(video) {
// do stuff with the video
});
For a post request, you could do the following:
api.addVideo({
body: {
categoryId: 1,
name: "My Video"
}
}, function(video) {
// do stuff with the video
});
Here's a PUT request example, passing an id (you'd need to ensure you have a objectId
attribute in the method definition:
api.updateVideo({
objectId: "123",
body: {
categoryId: 2,
name: "My Video2"
}
}, function(video) {
// do stuff with the video
});
Local definitions
Those apply when you decide to set those at a method definition level (for one endpoint only).
onError() and onLoad()
You can also pass the onLoad and onError handlers within each method - to have a unique response from each. In all cases you always get two params which are the response and the original callback so you can pass it through, or stop the call. Again with onError you can perform a retry() at a local level.
Override the base URL
Since version 1.3.6 it's now possible to have a complete URL in the method definition, for example, if you're using a base URL (url
top setting) and methods for your primary API, you might want to access another service for Push or Geocoding etc.
In this instance, you would specify a method and specify the GET, PUT etc as the full URL including the http://
or https://
intro. RESTe will ignore the base URL and any global request headers, and use your "local" URL entirely -- so add any headers required to the method definition.
api.config({
...
}, {
name: "pushNotification",
post: "http://another.api.service.com/push"
}, {
...
});
Override or add request headers
You can override or add new headers for each method (or endpoint) locally.
You can also use functions for those which will be executed every time this method is used from RESTe, giving you the ability to have dynamic parameters here. Pretty useful for Authorization
headers using dynamic tokens persisted somewhere else for example.
...
{
name: "getAccounts",
get: "user/accounts",
headers: {
"Authorization": function(){
return "Something";
}
}
}
...
Pro tip: If for whatever reason you need some settings to be more dynamic (maybe using global functions), you can even have self executed functions for any of those. Something like :
api.config({
...
}, {
name: "pushNotification",
post: (function(){ return "some/endpoint"; })()
}, {
...
});
Promise support with q.js
Download the q.js and place in your project (lib folder for Alloy). Then pass it to config as Q property.
api.config({
Q: require('q'),
...
});
Examples using Promise
api.getVideos().then(function(videos){}).then(...).catch(...);
Or call a method with a specific Id:
api.getVideoById({
videoId: "fUAM4ZFj9X"
}).then(function(video) {
// do stuff with the video
});
Helper functions
There are a couple of new functions to help in a couple of areas -- firstly, being able to swap out the base URL of your API -- useful if you're developing and need to switch servers in the app. The second method supports clearing any cookies from the RESTe http client.
The following will temporarily change the config base URL:
api.setUrl("http://whatever");
(this is lost if you restart the app)
The following will clear any cookies from the baseUrl:
api.clearCookies();
Alloy Collections and Model support
RESTe supports collection and model generation. So it supports creating and managing collections and models, binding, and CRUD methods to Create, Update and Delete models.
NOTE: If you are using the Alloy Collections and Model Support of RESTe, you should not use the Alloy Model / Collection definitions -- so you shouldn't have an app/models folder with models defined. You must also not use the notation in the XML -- you just use the dataCollection binding in a repeating element.
You can also now perform transform functions at a global (config) level or locally in a controller / view -- this is really useful if you use Alloy and pass models to views using $model
In the following example, we've defined a method called getExpenseQueueFull elsewhere in the config that gets expense details, and then defined a transform function in the config:
models: [{
name: "expense",
id: "unid",
read: "getExpenseById",
content: "retArray",
transform: function(m) {
m = m.toJSON();
m.hotelAllowance && (m.hotelAllowance = "Β£" + parseFloat(m.hotelAllowance).toFixed(2));
m.mileage && (m.mileage = "Β£" + parseFloat(m.mileage).toFixed(2));
m.other && (m.other = "Β£" + parseFloat(m.other).toFixed(2));
m.total && (m.total = "Β£" + parseFloat(m.total).toFixed(2));
return m;
},
collections: [{
name: "expenses",
content: "retArray",
read: "getExpensesQueueFull"
}],
}
So now whenever you want to transform the model, you can do so within a local transform function as follows:
function transform(model) {
var m = model.transform(model);
return m;
}
You can also pass an optional transform parameter in the transform function, which will override the global transform method.
Defining methods with models / collections
Using the following config you can configure end points that will still work as normal RESTe methods, but also give you collections and model support for (C)reate, (R)ead, (U)pdate, (D)elete. For Collections I use an array of collections so you can have multiple endpoints configured if different collections using the same model. This enables use of for example, Alloy.Collections.locations (for all locations) and Alloy.Collections.locationsByName (for locations by a specific parameter).
(Ideally this should be more elegant, allowing the single locations collection in this case to be used to filter content but I needed a way to make this API independant and it's the best I can do for now!)
models: [{
name: "location",
id: "objectId",
read: "getLocation",
//content: "results" <- use this is your method returns an array object
create: "createLocation",
update: "updateLocation",
delete: "deleteLocation",
collections: [{
name: "locations",
content: "results",
read: "getLocations"
}, {
name: "locationsByName",
content: "results",
read: "getLocationsByName"
}],
}],
methods: [{
name: "getLocations",
get: "classes/locations"
}, {
name: "getLocation",
get: "classes/locations/<id>"
},{
name: "getLocationsByName",
get: 'classes/locations?where={"name": "<name>"}'
}, {
name: "updateLocation",
put: "classes/locations/<id>"
}, {
name: "createLocation",
post: "classes/locations/"
}, {
name: "deleteLocation",
delete: "classes/locations/<id>"
}]
Using models / collections
In the example above, I can refresh the data for a collection by using:
Alloy.Collections.locations.fetch();
and bind it to a tableview as follows:
<TableView dataCollection="locations" onClick="selectLocation">
<TableViewRow id="locationRow" model="{objectId}" >
<Label class="title" top="10"left="20" text="{name}"/>
<Label class="subTitle" bottom="10" left="20" text="{address}"/>
</TableViewRow>
</TableView>
You could also send parameters like follows:
Alloy.Collections.locationsByName.fetch({
name: "home"
});
To sort a collection, you need to set the comparator to the collection. Don't do this in the API configuration, but on the collection itself before you fetch it, like shown in the example below.
Calling the sort function at any time after the fetch will try to sort.
Alloy.Collections.locations.comparator = function(a, b){
// do your sorting here, a & b will be models
};
Alloy.Collections.locations.fetch({
success: function(a,b,c){
Alloy.Collections.locations.sort();
}
});
Creating new models and collections
RESTe provides a couple of useful helper functions to create new models and collections - this is useful if you want to take an array of objects and turn them into a collection for easy binding.
.createModel(name, attributes)
.createCollection(name, array)
Each return either a model, or collection that can then be used with Alloy.
When working with created models, you can define an instance of a model that you've specified in the config, and if that supports CRUD functions, you can pass options when creating, saving, updating and deleting.
So for example:
var user = Alloy.Globals.reste.createModel("user");
user.save({
username: $.email.value,
firstname: $.firstname.value,
lastname: $.lastname.value,
email: $.email.value,
password: $.password.value
}, {
success: function(e, response) {
console.log("User saved!");
console.log(user.toJSON());
},
error: function(e, response) {
console.log("Error saving user!");
console.log(response);
}
});
License
Copyright Jason Kneen Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.