Interact with REST apis using realm.io to represent objects. The goal of ApiModel
is to be easy to setup, easy to grasp, and fun to use. Boilerplate should be kept to a minimum, and also intuitive to set up.
This project is very much inspired by @idlefingers' excellent api-model.
Add APIModel
to your Podfile
, and run pod install
:
pod 'APIModel', '~> 0.13.0'
The key part is to implement the ApiModel
protocol.
import RealmSwift
import ApiModel
class Post: Object, ApiModel {
// Standard Realm boilerplate
dynamic var id = ""
dynamic var title = ""
dynamic var contents = ""
dynamic lazy var createdAt = NSDate()
override class func primaryKey() -> String {
return "id"
}
// Define the standard namespace this class usually resides in JSON responses
// MUST BE singular ie `post` not `posts`
class func apiNamespace() -> String {
return "post"
}
// Define where and how to get these. Routes are assumed to use Rails style REST (index, show, update, destroy)
class func apiRoutes() -> ApiRoutes {
return ApiRoutes(
index: "/posts.json",
show: "/post/:id:.json"
)
}
// Define how it is converted from JSON responses into Realm objects. A host of transforms are available
// See section "Transforms" in README. They are super easy to create as well!
class func fromJSONMapping() -> JSONMapping {
return [
"id": ApiIdTransform(),
"title": StringTransform(),
"contents": StringTransform(),
"createdAt": NSDateTransform()
]
}
// Define how this object is to be serialized back into a server response format
func JSONDictionary() -> [String:Any] {
return [
"id": id,
"title": email,
"contents": contents,
"created_at": createdAt
]
}
}
To represent the API itself, you have to create an object of the ApiManager
class. This holds a ApiConfiguration
object defining the host URL for all requests. After it has been created it can be accessed from the func apiManager() -> ApiManager
singleton function.
To set it up:
// Put this somewhere in your AppDelegate or together with other initialization code
var apiConfig = ApiConfig(host: "https://service.io/api/v1/")
ApiSingleton.setInstance(ApiManager(configuration: apiConfig))
If you would like to disable request logging, you can do so by setting requestLogging
to false
:
apiConfig.requestLogging = false
If you would like ApiModel to use a NSURLSessionConfiguration, you can set it like in the following example:
let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
configuration.timeoutIntervalForRequest = 15 // shorten default timeout
configuration.timeoutIntervalForResource = 15 // shorten default timeout
ApiSingleton.setInstance(ApiManager(config: ApiConfig(host: "http://feed.myapi.com", urlSessionConfig:configuration)))
// or
//...
apiConfig.urlSessionConfig = configuration
For the most part an API is consistent across endpoints, however in the real world, conventions usually differ wildly. The global configuration is the one set by calling ApiSingleton.setInstance(ApiManager(configuration: apiConfig))
.
To have a model-local configuration a model needs to implement the ApiConfigurable
protocol, which consists of a single method:
public protocol ApiConfigurable {
static func apiConfig(config: ApiConfig) -> ApiConfig
}
Input is the root base configuration and output is the model's own config object. The object passed in is a copy of the root configuration, so you are free to modify that object without any side-effects.
static func apiConfig(config: ApiConfig) -> ApiConfig {
config.encoding = ApiRequest.FormDataEncoding
return config
}
The base of ApiModel
is the Api
wrapper class. This class wraps an Object
type and takes care of fetching objects, saving objects and dealing with validation errors.
ApiModel
supports querying API's using basic HTTP verbs.
// GET call without parameters
Api<Post>.get("/v1/posts.json") { response in
println("response.isSuccessful: \(response.isSuccessful)")
println("Response as an array: \(response.array)")
println("Response as a dictionary: \(response.dictionary)")
println("Response errors?: \(response.errors)")
}
// Other supported methods:
Api<Post>.get(path, parameters: [String:AnyObject]) { response // ...
Api<Post>.post(path, parameters: [String:AnyObject]) { response // ...
Api<Post>.put(path, parameters: [String:AnyObject]) { response // ...
Api<Post>.delete(path, parameters: [String:AnyObject]) { response // ...
// no parameters
Api<Post>.get(path) { response // ...
Api<Post>.post(path) { response // ...
Api<Post>.put(path) { response // ...
Api<Post>.delete(path) { response // ...
// You can also pass in custom `ApiConfig` into each of the above mentioned methods:
Api<Post>.get(path, parameters: [String:AnyObject], apiConfig: ApiConfig) { response // ...
Api<Post>.post(path, parameters: [String:AnyObject], apiConfig: ApiConfig) { response // ...
Api<Post>.put(path, parameters: [String:AnyObject], apiConfig: ApiConfig) { response // ...
Api<Post>.delete(path, parameters: [String:AnyObject], apiConfig: ApiConfig) { response // ...
Most of the time you'll want to use the ActiveRecord
-style verbs index/show/create/update
for interacting with a REST API, as described below.
Using the index
of a REST resource:
GET /posts.json
Api<Post>.findArray { posts, response in
for post in posts {
println("... \(post.title)")
}
}
Using the show
of a REST resource:
GET /user.json
Api<User>.find { user, response in
if let user = user {
println("User is: \(user.email)")
} else {
println("Error loading user")
}
}
var post = Post()
post.title = NSLocalizedString("Hello world - A prologue", comment: "")
post.contents = "Hello!"
post.createdAt = NSDate()
var form = Api<Post>(model: post)
form.save { _ in
if form.hasErrors {
println("Could not save:")
for error in form.errorMessages {
println("... \(error)")
}
} else {
println("Saved! Post #\(post.id)")
}
}
Api
will know that the object is not persisted, since it does not have an id
set (or which ever field is defined as primaryKey
in Realm). So a POST
request will be made as follows:
POST /posts.json
{
"post": {
"title": "Hello world - A prologue",
"contents": "Hello!",
"created_at": "2015-03-08T14:19:31-01:00"
}
}
If the response is successful, the attributes returned by the server will be updated on the model.
200 OK
{
"post": {
"id": 1
}
}
The errors are expected to be in the format:
400 BAD REQUEST
{
"post": {
"errors": {
"contents": [
"must be longer than 140 characters"
]
}
}
}
And this will make it possible to access the errors as follows:
form.errors["contents"] // -> [String]
// or
form.errorMessages // -> [String]
Transforms are used to convert attributes from JSON responses to rich types. The easiest way to explain is to show a simple transform.
ApiModel
comes with a host of standard transforms. An example is the IntTransform
:
import RealmSwift
class IntTransform: Transform {
func perform(value: AnyObject?, realm: Realm?) -> AnyObject {
if let asInt = value?.integerValue {
return asInt
} else {
return 0
}
}
}
This takes an object and attempts to convert it into an integer. If that fails, it returns the default value 0.
Transforms can be quite complex, and even convert nested models. For example:
class User: Object, ApiModel {
dynamic var id = ApiId()
dynamic var email = ""
let posts = List<Post>()
static func fromJSONMapping() -> JSONMapping {
return [
"posts": ArrayTransform<Post>()
]
}
}
Api<User>.find { user, response in
println("User: \(user.email)")
for post in user.posts {
println("\(post.title)")
}
}
Default transforms are:
- StringTransform
- IntTransform
- FloatTransform
- DoubleTransform
- BoolTransform
- NSDateTransform
- ModelTransform
- ArrayTransform
- PercentageTransform
However, it is really easy to define your own. Go nuts!
Date and time parsing is always a bit complex and has a lot of subtle nuances. The NSDateTransform
takes a string and tries to convert it into an NSDate
object. If it can't it returns nil
.
Dates come in plenty of different formats. The default format ApiModel
and NSDateTransform
uses is called ISO 8601
class Post: Object, APIModel {
class func fromJSONMapping() -> JSONMapping {
return [
"createdAt": NSDateTransform()
]
}
}
// Example of a valid string:
// "2015-12-30T12:12:33.000Z"
In the real world you will come across many wildly different date formats and many APIs will have different opinions on how to represent a date. Therefor you can also pass in a custom format string into NSDateTransform
:
class Post: Object, APIModel {
class func fromJSONMapping() -> JSONMapping {
return [
"createdAt": NSDateTransform(dateFormat: "yyyy-MM-dd")
]
}
}
// Example of a valid string:
// "2015-12-30"
For a complete reference on date format specifiers, visit the unicode reference.
Internally NSDate
stores the date as seconds from a specific reference date. That means it will not store any information about the time zone, even if one is provided by the date string. For example, let's assume this string is returned from the API: 2015-12-30T18:12:33.000-05:00
. NSDate
only uses the provided offset to offset the resulting time correctly, then it is thrown away.
What you need to do as an app developer is to make sure to always pass in the correct time zone when you wish to display the timestamp. Normally using an NSDateFormatter
. By default NSDateFormatter
uses the user time zone, so you should never need to worry about that. Check the following example for reference:
let dateString = "2015-12-30T18:12:33.000-05:00"
// Parse as ISO 8601
let dateFormatter = NSDateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
let date = dateFormatter.dateFromString(dateString)!
let outputFormatter = NSDateFormatter()
outputFormatter.dateFormat = "yyyy-MM-dd HH:mm"
// prints "2015-12-30 23:12"
outputFormatter.timeZone = NSTimeZone(abbreviation: "GMT")
print(outputFormatter.stringFromDate(date))
// prints "2015-12-31 00:12"
outputFormatter.timeZone = NSTimeZone(abbreviation: "Europe/Stockholm") // +02:00
print(outputFormatter.stringFromDate(date))
// prints "2015-12-30 18:12"
outputFormatter.timeZone = NSTimeZone(abbreviation: "EST") // -05:00 SAME AS INPUT
print(outputFormatter.stringFromDate(date))
Rule of thumb: You should only think about time zones when displaying NSDate
s.
ApiModel
uses Alamofire for sending and receiving requests. To hook into this, the API
class currently has before
- and after
-hooks that you can use to modify or log the requests. An example of sending user credentials with each request:
// Put this somewhere in your AppDelegate or together with other initialization code
api().beforeRequest { request in
if let loginToken = User.loginToken() {
request.parameters["access_token"] = loginToken
}
}
There is also a afterRequest
which passes in a ApiRequest
and ApiResponse
:
api().afterRequest { request, response in
println("... Got: \(response.status)")
println("... \(request)")
println("... \(response)")
}
Given the setup for the Post
model above, if you wanted to get the full url with replacements for the show resource (like https://service.io/api/v1/posts/123.json
), you can use:
post.apiUrlForRoute(Post.apiRoutes().show)
// NOT IMPLEMENTED YET BECAUSE LIMITATIONS IN SWIFT: post.apiUrlForResource(.Show)
As a consumer of an API, you never want to make assumptions about the ID structure used for their models. Do not use Int
or anything similar for ID types, strings are to be recommended. Therefor ApiModel
defines a typealias to String
, called ApiId. There is also an ApiIdTransform
available for IDs.
Some API's wrap all their responses in an "envelope", a container that is generic for all responses. For example an API might wrap all response data within a data
-property of the root JSON:
{
"data": {
"user": { ... }
}
}
To deal with this gracefully there is a configuration option on the ApiConfig
class called rootNamespace
. This is a dot-separated path that is traversed for each response. To deal with the above example you would simply:
let config = ApiConfig()
config.rootNamespace = "data"
It can also be more complex, for example if the envelope looked something like this:
{
"JsonResponseEnvelope": {
"SuccessFullJsonResponse": {
"SoapResponseContainer": {
"EnterpriseBeanData": {
"user": { ... }
}
}
}
}
}
This would then convert into the rootNamespace
:
let config = ApiConfig()
config.rootNamespace = "JsonResponseEnvelope.SuccessFullJsonResponse.SoapResponseContainer.EnterpriseBeanData"
It is up to you to cache and store the results of any calls. ApiModel does not do that for you, and will not do that, since strategies vary wildly depending on needs.
Just as the JSONDictionary
can return a dictionary of parameters to be sent to the server, it can also contain NSData
values that can be uploaded to a server. For example you could convert UIImage
s to NSData
and upload them for profile images.
The standard way of uploading files on the web is using the content-type multipart/form-data
, which is slightly different from JSON. If you have a model that should be able to support file uploads, you can configure the model to encode it's JSONDictionary
into multipart/form-data
.
The following example illustrates a UserAvatar
model:
import RealmSwift
import ApiModel
import UIKit
class UserAvatar: Object, ApiModel, ApiConfigurable {
dynamic var userId = ApiId()
dynamic var url = String() // generated on the server
var imageData: NSData?
class func apiConfig(config: ApiConfig) -> ApiConfig {
// ApiRequest.FormDataEncoding is where the magic happens
// It tells ApiModel to encode everything with `multipart/form-data`
config.encoding = ApiRequest.FormDataEncoding
return config
}
// Important because the `imageData` property cannot be represented by Realm
override class func ignoredProperties() -> [String] {
return ["imageData"]
}
override class func primaryKey() -> String {
return "userId"
}
class func apiNamespace() -> String {
return "user_avatar"
}
class func apiRoutes() -> ApiRoutes {
return ApiRoutes.resource("/user/avatar.json")
}
class func fromJSONMapping() -> JSONMapping {
return [
"userId": ApiIdTransform(),
"url": StringTransform()
]
}
func JSONDictionary() -> [String:Any] {
return [
"image": FileUpload(fileName: "avatar.jpg", mimeType: "image/jpg", data: imageData!)
]
}
}
func upload() {
let image = UIImage(named: "me.jpg")!
let userAvatar = UserAvatar()
userAvatar.userId = "1"
userAvatar.imageData = UIImageJPEGRepresentation(image, 1)!
Api(model: userAvatar).save { form in
if form.hasErrors {
print("Could not upload file: " + form.errorMessages.joinWithSeparator("\n"))
} else {
print("File uploaded! URL: \(userAvatar.url)")
}
}
}
You can upload any file this way, not only images. Any NSData can be uploaded. The default mime type for uploaded files is application/octet-stream
. If you need to configure this there is a special object you need to construct called FileUpload
.
The constructor is illustrated above, and takes the file name the server receives, mimetype of the file, and the data.
FileUpload(fileName: "document.pdf", mimeType: "application/pdf", data: documentData)
This should be put in the JSONDictionary
dictionary. ApiModel will detect it and encode it accordingly.
The MIT License (MIT)
Copyright (c) 2015 Rootof Creations HB
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.