can-rest-model
Connect a type to a restful service layer.
restModel(options)
restModel
extends the provided options.Map
type
with the ability to connect to a restful service layer. For example,
the following extends a Todo
type
with the ability to connect to a restful service layer:
import {Todo, todoFixture} from "//unpkg.com/can-demo-models@5";
import {restModel} from "can";
// Creates a mock backend with 5 todos
todoFixture(5);
Todo.connection = restModel({
Map: Todo,
List: Todo.List,
url: "/api/todos/{id}"
});
// Prints out all todo names
Todo.getList().then(todos => {
todos.forEach(todo => {
console.log(todo.name);
})
})
restModel
mixes in the following behaviors:
Parameters
- options
{Object}
:Configuration options supported by all the mixed-in behaviors:
Map - The map type constructor function used to create instances of the raw record data retrieved from the server. The type will also be decorated with the following methods:
List - The list type constructor function used to create a list of instances of the raw record data retrieved from the server.
url - Configure the URLs used to create, retrieve, update and delete data. It can be configured with a single url like:
url: "/api/todos/{id}"
Or an object that configures how to create, retrieve, update and delete individually:
url: { getListData: "GET /api/todos/find", getData: "GET /api/todo/get/{id}", createData: "POST /api/todo/create", updateData: "POST /api/todo/update?id={id}", destroyData: "POST /api/todo/delete?id={id}" }
ajax - Specify a method to use to make requests; can-ajax is used by default, but jQuery's
.ajax
method can be passed.parseInstanceProp - Specify the property to find the data that represents an instance item.
parseInstanceData - Returns the properties that should be used to make an instance given the results of getData, createData, updateData, and destroyData.
parseListProp Specify the property to find the list data within a
getList
response.parseListData Return the correctly formatted data for a
getList
response.queryLogic - Specify the identity properties of the type. This is built automatically from the
Map
if can-define/map/map is used.
Returns
{connection}
:
Returns a connection object.
restModel(url)
Create a connection with just a url. Use this if you do not need to pass in any other options
to configure the connection.
For example, the following creates a Todo
type with the ability to connect to a restful service layer:
import {todoFixture} from "//unpkg.com/can-demo-models@5";
import {restModel} from "can";
// Creates a mock backend with 5 todos
todoFixture(5);
const Todo = restModel("/api/todos/{id}").Map;
// Prints out all todo names
Todo.getList().then(todos => {
todos.forEach(todo => {
console.log(todo.name);
})
})
Parameters
- url
{String}
:The url used to create, retrieve, update and delete data.
Returns
{connection}
:
A connection that is the combination of the options and all the behaviors
that restModel
adds. The connection
includes a Map
property which is the type
constructor function used to create instances of the raw record data retrieved from the server.
Use
Use restModel
to build a simple connection to a restful service
layer. To use restModel
, you:
- Define data types to connect to the service layer
- Configure the connection to the service layer
- Use the types to manipulate service data
restModel
is the most
basic built-in CanJS model layer. Check out can-realtime-rest-model for models that
are able to:
- Add and remove data from lists automatically
- Unify instances across requests
Define data types
The first step in creating a model is to define the types that will be used to hold and manipulate data on the server. The following defines:
- a
Todo
type to represent an individual todo's data TodoList
type to represent a list of todos
import {DefineMap, DefineList, restModel} from "can";
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: "boolean",
createdAt: "date",
toggle(){
this.complete = !this.complete;
}
})
Todo.List = DefineList.extend("TodoList",{
"#": Todo,
get completeCount(){
return this.filter({complete: true}).length;
}
});
Notice that properties and methods are defined on the types. While any of CanJS's map-types can be used to create a model, can-define/map/map currently is the easiest to configure.
Nested data type or data types with relationships
Sometimes your data might include nested data and/or related data. For example, if you
get todo
5's data at /api/todos/5
and it returns a nested assignedTo
as follows:
{
id: 5,
name: "mow lawn",
complete: false,
assignedTo: {
id: 28,
userName: "Justin Meyer"
}
}
You typically want to define that nested value as another type like:
const User = DefineMap.extend("User",{
id: "number",
userName: "string"
});
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: "boolean",
assignedTo: User,
toggle(){
this.complete = !this.complete;
}
});
Check out the can/ref behavior for additional relationship features.
If you are using can-define/map/map and your server might add properties that can't be defined beforehand, make sure to unseal your todo type:
const Todo = DefineMap.extend("Todo",
{
seal: false
},
{
id: {type: "number", identity: true},
name: "string",
complete: "boolean",
toggle(){
this.complete = !this.complete;
}
});
Often with document-based data structures, it's nice to have a reference to the "root"
data object on all child objects. For example, todo
data might have a list of subtasks, each
with their own name and complete status:
{
id: 5,
name: "mow lawn",
complete: false,
subtasks: [
{name: "get gas", complete: true},
{name: "sharpen blades", complete: false}
]
}
It can be nice to have the individual subtasks have a reference to their parent todo
. For example, this
makes updating the subtask easier. The following makes it so calling a subtask
's .save()
actually
calls it's todo
's .save()
method:
import {DefineMap, DefineList, restModel} from "//unpkg.com/can@5/core.mjs";
import {todoFixture} from "//unpkg.com/can-demo-models@5";
// Model subtask
const Subtask = DefineMap.extend("Subtask",{
name: "string",
complete: "boolean",
// parentTodo should not be serialized
parentTodo: {serialize: false, type: "any"},
// a save utility that actually saves the parent todo
save(){
this.parentTodo.save();
}
});
// Model a list of subtasks to add the `parentTodo` to all subtasks
Subtask.List = DefineList.extend("Subtasks",{
// Defines the items in the subtasks list
"#": {
Type: Subtask,
// If subtasks are added, set their parentTodo
added(subtasks){
if(this.parentTodo) {
subtasks.forEach((subtask) => {
subtask.parentTodo = this.parentTodo;
})
}
return subtasks;
},
// If subtasks are removed, remove their parentTodo
removed(subtasks) {
subtasks.forEach((subtask) => {
subtask.parentTodo = null;
})
}
},
// If parentTodo is set, update all the subtasks' parentTodo
parentTodo: {
set(parentTodo){
this.forEach(function(subtask){
subtask.parentTodo = parentTodo;
});
return parentTodo;
},
serialize: false
}
});
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: "boolean",
// Make it so when subtasks is set, it sets
// the parentTodo reference:
subtasks: {
Type: Subtask.List,
set(subtasks){
subtasks.parentTodo = this;
return subtasks;
}
},
toggle(){
this.complete = !this.complete;
}
});
Todo.List = DefineList.extend("TodoList",{
"#": Todo,
});
// Sets up a can-fixture as the backend
todoFixture(0);
// Creates a restModel
Todo.connection = restModel({
Map: Todo,
List: Todo.List,
url: "/api/todos/{id}"
});
// Creates a new todo with one subtask
let myTodo = new Todo({
name: "learn canjs", completed: false,
subtasks: [{name: "learn js", completed: false}]
});
// Modifies and saves the subtask (thus saving the entire todo)
myTodo.subtasks[0].completed = true;
myTodo.subtasks[0].save();
// Reads the newly saved todo from the backend and prints it's completed status
Todo.getList().then(todos => console.log(todos[0].subtasks[0].completed));
The identity property
If you're specifying the identity property on nested data types, restModel
will be able to
intelligently merge data. For example, say a Todo
and its nested User
type are defined as follows:
const User = DefineMap.extend("User",{
id: "number",
name: "string"
});
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: "boolean",
assignedTo: [User]
});
If a todo like the following:
let justin = new User({id: 20, name: "Justin"}),
ramiya = new User({id: 21, name: "Ramiya"});
let todo = new Todo({
id: 1,
name: "mow lawn",
complete: false,
assignedTo: [justin, ramiya]
});
is updated with data like:
{
id: 1,
name: "mow lawn",
complete: true,
assignedTo: [{
id: 21, name: "Ramiya Meyer"
}]
}
Without specifying the identity property of User
, the justin
instance's id
and name
will be updated, not the ramiya
instance's like you might expect:
justin.id //-> 21
justin.name //-> "Ramiya Meyer"
However, if the User
object's id
property is specified with an identity: true
flag as follows:
const User = DefineMap.extend("User",{
id: {type: "number", identity: true},
name: "string"
});
When the update happens, the ramiya
instance will be updated correctly:
ramiya.id //-> 21
ramiya.name //-> "Ramiya Meyer"
Configure the connection
Once you have your types defined, the next step is to configure your connection to make requests to your service layer and create these types.
If your service layer matches what CanJS expects, this configuration might be as simple as the following:
Todo.connection = restModel({
Map: Todo,
List: Todo.List,
url: "/api/todos/{id}"
});
This configuration assumes the following:
GET /api/todos
is used to retrieve a list of todos. It returns a JSON response like:{ data: [ { id: 5, name: "mow lawn", complete: false }, ... ], totalCount: 20 }
Note that an object is returned with a
data
array. The array contains the data that will be used to create instances of theTodo
type. Other properties on the object (ex:totalCount
) will be added to the list type. The data above produces:todos instanceof Todo.List //-> true todos.totalCount //-> 20 todos[0] instanceof Todo //-> true todos[0].id //-> 5
GET /api/todos/5
is used to retrieve a single todo. It returns a JSON response like:{ id: 5, name: "mow lawn", complete: false }
Note that the object returned contains the values that will be used to create a
Todo
instance.POST /api/todos
is used to create a single todo record. It should take a JSON request body of the properties on a todo record like:{ name: "do dishes", complete: false }
The server should return a JSON response with the identity properties and any other values that should be included on the object:
{ id: 6, name: "do dishes", complete: false, createdAt: "2018-04-18" }
PUT /api/todos/6
is used to update a todo record. It should take aJSON
request body of the properties of the todo record (with the exception of the identity keys) like:{ name: "do dishes", complete: true, createdAt: "2018-04-18" }
The server should return a JSON response with the full record:
{ id: 6, name: "do dishes", complete: true, createdAt: "2018-04-18" }
DELETE /api/todos/6
is used to delete a todo record. The server should return the record data:{ id: 6, name: "do dishes", complete: true, createdAt: "2018-04-18" }
or an empty successful response.
If your service layer doesn't match what CanJS expects, then you can configure either how the request is made or how the response is parsed.
The url
option can be configured with individual urls used to create, retrieve, update
and delete data:
Todo.connection = restModel({
Map: Todo,
List: Todo.List,
url: {
getListData: "GET /api/todos/find",
getData: "GET /api/todo/get/{id}",
createData: "POST /api/todo/create",
updateData: "POST /api/todo/update?id={id}",
destroyData: "POST /api/todo/delete?id={id}"
}
});
You can also supply functions to retrieve the data yourself and return a promise that
resolves to the expected data format. The following makes getListData
use
fetch
to request JSON data:
import { param, restModel } from "can";
Todo.connection = restModel({
Map: Todo,
List: Todo.List,
url: {
getListData: function(query) {
return fetch("/api/todos/find?"+param(query)).then(function(response){
return response.json();
})
},
getData: "GET /api/todo/get/{id}",
createData: "POST /api/todo/create",
updateData: "POST /api/todo/update?id={id}",
destroyData: "POST /api/todo/delete?id={id}"
}
});
If the response data doesn't match the expected format, you can either fix it in
functions like getListData
above or use
parseInstanceProp, parseListProp,
parseInstanceData or parseListData
to fix the formatting. For example, if GET /api/todos
returned data like:
{
todos: [
{ id: 5, name: "mow lawn", complete: false },
...
],
totalCount: 20
}
You could correct this with parseListProp like:
Todo.connection = restModel({
Map: Todo,
List: Todo.List,
url: "/api/todos/{id}",
parseListProp: "todos"
});
Manipulate service data
The below code allows one to retrieve, create, update, and destroy instances using
methods on Todo
and instances of Todo
:
import {Todo, todoFixture} from "//unpkg.com/can-demo-models@5";
import {restModel} from "can";
// Creates a mock backend with 5 todos
todoFixture(5);
Todo.connection = restModel({
Map: Todo,
List: Todo.List,
url: "/api/todos/{id}"
});
// get a list of todos
Todo.getList({filter: {complete: true}}) //-> Promise<Todo.List>
// get a single todo
Todo.get({id: 4}) //-> Promise<Todo>
// create a todo and persist it to the server:
let todo = new Todo({name: "learn canjs", complete: false})
todo.save() //-> Promise<Todo>
// update the todo and persist changes to the server:
todo.complete = true;
todo.save() //-> Promise<Todo>
// prints out all complete todos including the new one
Todo.getList({filter: {complete: true}})
.then(todos => todos.forEach(todo => console.log(todo.name)))
// delete the todo on the server
todo.destroy() //-> Promise<Todo>
restModel
also mixes in methods that let you know if the
object is being saved, destroyed, or has already been created:
todo.isSaving() //-> Boolean
todo.isDestroying() //-> Boolean
todo.isNew() //-> Boolean
These methods are observable, so they can be read in a template and the template will automatically update:
<button disabled:from="todo.isSaving()">Update</button>
restModel
also makes the type and instances of the type emit events when items
are created, updated or destroyed:
Todo.on("created", function(ev, newTodo) {
console.log("Todo created event");
});
let todo = new Todo({name: "mow lawn"});
todo.on("created", function(){
console.log("todo created event");
})
todo.save()
//-> logs "todo created event"
// logs "Todo created event"