can-connect
can-connect
provides persisted data middleware. Assemble powerful model layers from fully modularized behaviors (i.e. plugins).
connect(behaviors, options)
Iterate through passed behaviors and assemble them into a connection.
import connect from "can-connect";
import dataUrl from "can-connect/data/url/";
import connectConstructor from "can-connect/constructor/";
const todosConnection = connect( [ dataUrl, connectConstructor ], {
url: "/api/todos"
} );
Parameters
- behaviors
{Array<can-connect/Behavior>}
:An array of behaviors that will be used to compose the final connection.
- options
{Object}
:An object of configuration parameters for the behaviors in the connection.
Purpose
can-connect
provides a wide variety of functionality useful for building
data models. Data models are used to organize how an application
connects with persisted data. Data models can greatly simplify higher order
functionality like components. Most commonly, can-connect
is used to
create data models that make it easy to create, retrieve, update, and
delete (CRUD) data by making HTTP requests to a backend server's service layer.
Unfortunately, there is a wide variety of service layer APIs. While REST, JSONAPI, GRAPHQL and other specifications attempt to create a uniform service layer, a one-size-all approach would leave many CanJS users having to build their own data layer from scratch.
Thus, can-connect
's primary design goal is flexibility and re-usability,
not ease of use. Some assembly is required! For easier, pre-assembled, data models,
checkout:
- can-rest-model - Connect a data type to a restful service layer.
- can-realtime-rest-model - Same as can-rest-model, but adds automatic list management.
- can-super-model - Same as can-realtime-rest-model, but adds fall-through localStorage caching.
can-connect
includes behaviors used to assemble data models. It includes the
following behaviors that:
Load data:
data/url — Persist data to RESTful or other types of HTTP services.
data/parse — Convert response data into a format needed for other extensions.
Convert data into special types:
constructor/ — Create instances of a provided constructor function or list type.
constructor/store — Prevent multiple instances of a given id or multiple lists of a given set from being created.
Keep lists updated with the most current data:
- real-time — Lists updated when instances are created or deleted.
Implement caching strategies:
fall-through-cache — Use cache data if possible when creating instances, then update the instance with server data upon completion of a background request.
cache-requests — Cache response data and use it for future requests.
data/combine-requests — Combine overlapping or redundant requests.
Provide caching storage (as a cacheConnection):
data/localstorage-cache — LocalStorage caching connection.
data/memory-cache — In-memory caching connection.
Glue certain behaviors together:
data/callbacks — Add callback hooks are passed the results of the DataInterface methods (CRUD operations).
data/callbacks-cache — Handle data/callbacks and update the cache when CRUD operations complete.
Provide convenient integration with CanJS:
can/map — Create can-define/map/map or can-define/list/list instances from responses. Adds connection-aware methods to configured types.
can/ref - Handle references to other instances in the raw data responses.
can/merge - Minimize updates to deeply nested instance data when new data is returned from the server.
can/constructor-hydrate - Always check the instanceStore when creating new instances of the connection Map type.
Overview
The can-connect
module exports a connect
function that is used to assemble different behaviors (plugins)
and some configuration options into a connection
. For example, the following uses connect
and the
constructor/constructor and data/url behaviors
to create a todoConnection
connection:
import connect from "can-connect";
import constructor from "can-connect/constructor/";
import dataUrl from "can-connect/data/url/";
const todoConnection = connect(
[ constructor, dataUrl ],
{ url: "/services/todos" }
);
A typical connection provides the ability to create, read, update, or delete (CRUD) data hosted by some data source. Those operations are usually performed via the connection InstanceInterface methods:
For example, to get all todos from "GET /services/todos", we could write the following:
todoConnection.getList( {} ).then( function( todos ) { /* ... */ } );
Behaviors, like constructor/constructor and data/url implement, extend, or require some set of interfaces. For example, data/url implements the DataInterface methods, and constructor/constructor implements the InstanceInterface methods.
The connect
method arranges these behaviors in the right order to create a connection. For instance, the
cache-requests behavior must be applied after the data/url
connection. This is because cache-requests, overwrites data/url’s
connection.getListData method to first check the cache for the data. Only if the
data is not found, does it call data/urls’s getListData.
This behavior arranging makes it so even if we write:
import dataUrl from "can-connect/data/url/";
import cacheRequests from "can-connect/cache-requests/";
connect( [ cacheRequests, dataUrl ] );
or
connect([dataUrl, cacheRequests])
... the connection will be built in the right order!
A connection is just an object with each specified behavior on its prototype chain and the passed options object at the end of the prototype chain.
Basic Use
When starting out with can-connect
, it’s advisable to start out with the most basic behaviors:
data/url and constructor/constructor.
data/url adds an implementation of the DataInterface that connects to a RESTful data source.
constructor/constructor adds an implementation of the InstanceInterface
that can create, read, update and delete typed data via the lower-level DataInterface.
By "typed" data we mean data that is more than just plain JavaScript objects. For example, we want might to create
Todo
objects that implement an isComplete
method:
import assign from "can-assign";
const Todo = function( props ) {
assign( this, props );
};
Todo.prototype.isComplete = function() {
return this.status === "complete";
};
And, we might want a special TodoList
type with implementations of completed
and active
methods:
const TodoList = function( todos ) {
[].push.apply( this, todos );
};
TodoList.prototype = Object.create( Array.prototype );
TodoList.prototype.completed = function() {
return this.filter( function( todo ) {
return todo.status === "complete";
} );
};
TodoList.prototype.active = function() {
return this.filter( function( todo ) {
return todo.status !== "complete";
} );
};
We can create a connection that connects a RESTful "/api/todos" endpoint to Todo
instances and TodoList
lists like:
import connect from "can-connect";
const todoConnection = connect( [
require( "can-connect/constructor/constructor" ),
require( "can-connect/data/url/url" )
], {
url: "/todos",
list: function( listData, set ) {
return new TodoList( listData.data );
},
instance: function( props ) {
return new Todo( props );
}
} );
and then use that connection to get a TodoList
of Todo
s and render some markup:
const render = function( todos ) {
return "<ul>" + todos.map( function( todo ) {
return "<li>" + todo.name +
"<input type='checkbox' " +
( todo.isComplete() ? "checked" : "" ) + "/></li>";
} ).join( "" ) + "</ul>";
};
todoConnection.getList( {} ).then( function( todos ) {
const todosEl = document.getElementById( "todos-list" );
todosEl.innerHTML = "<h2>Active</h2>" +
render( todos.active() ) +
"<h2>Complete</h2>" +
render( todos.completed() );
} );
The following demo shows the result:
This connection also lets you create, update, and destroy a Todo instance. First create a todo instance:
const todo = new Todo( {
name: "take out trash"
} );
Then, use the connection's save
to create and update the todo, and destroy
to delete it:
// POSTs to /api/todos with JSON request body {name:"take out trash"}
// server returns {id: 5}
todoConnection.save( todo ).then( function( todo ) {
todo.id; //-> 5
todo.name = "take out garbage";
// PUTs to /api/todos/5 with JSON request body {name:"take out garbage"}
// server returns {id: 5, name:"take out garbage"}
todoConnection.save( todo ).then( function( todo ) {
// DELETEs to /api/todos/5
// server returns {}
todoConnection.destroy( todo ).then( function( todo ) {
} );
} );
} );
Behavior Configuration
Whenever connect
creates a connection, it always adds the base behavior.
This behavior is where the configuration options passed to connect
are stored and it also defines
several configurable options that are used by most other behaviors. For example, if your backend
uses a property named _id
to uniquely identify todos, you can specify this with
queryLogic like:
import constructor from "can-connect/constructor/";
import dataUrl from "can-connect/data/url/";
import QueryLogic from "can-query-logic";
var queryLogic = new QueryLogic({identity: ["_id"]});
const todoConnection = connect(
[ constructor, dataUrl ],
{
url: "/api/todos",
queryLogic: queryLogic
}
);
Behaviors list their configurable options in their own documentation pages.
Behavior Overwriting
If the configuration options available for a behavior are not enough, it's possible to overwrite any behavior with your own behavior.
For example, constructor/constructor’s
updatedInstance method sets the instance’s
properties to match the result of updateData. But if the
PUT /api/todos/5 {name:"take out garbage"}
request returns {}
, the following example would
result in a todo with only an id
property:
const todo = new Todo( { id: 5, name: "take out garbage" } );
// PUTs to /api/todos/5 with JSON request body {name:"take out garbage"}
// server returns {}
todoConnection.save( todo ).then( function( todo ) {
todo.id; //-> 5
todo.name; //-> undefined
} );
The following overwrites constructor/constructor’s
implementation of updateData
:
import constructor from "can-connect/constructor/";
import dataUrl from "can-connect/data/url/";
const mergeDataBehavior = {
updateData: function( instance, data ) {
Object.assign( instance, data );
}
};
const todoConnection = connect( [
constructor,
dataUrl,
mergeDataBehavior
], {
url: "/api/todos"
} );
You can add your own behavior that can overwrite any underlying behaviors by adding it to the end of the behaviors list.
CanJS use
If you are using CanJS, you can either:
use the can/map behavior which provides many connection methods and settings to integrate closely with can-define/map and can-define/list types.
use the can/super-map helper to create a connection that bundles can/map and many of the other behaviors.
Using can/map to create a connection looks like:
import DefineMap from "can-define/map/map";
import DefineList from "can-define/list/list";
import dataUrl from "can-connect/data/url/url";
import constructor from "can-connect/constructor/constructor";
import constructorStore from "can-connect/constructor/store/store";
import canMap from "can-connect/can/map/map";
const Todo = DefineMap.extend( { /* ... */ } );
Todo.List = DefineList.extend( {
"#": Todo
} );
const todoConnection = connect(
[ dataUrl, constructor, constructorStore, canMap ],
{
Map: Todo,
url: "/todos"
}
);
Using can-connect/can/super-map/super-map to create a connection looks like:
import DefineMap from "can-define/map/";
import DefineList from "can-define/list/";
import superMap from "can-connect/can/super-map/";
const Todo = DefineMap.extend( { /* ... */ } );
Todo.List = DefineList.extend( {
"#": Todo
} );
const todoConnection = superMap( {
Map: Todo,
url: "/todos"
} );
Both the above connections contain the constructor/store behavior.
This means when you create a binding to a Todo
or Todo.List
instance, they will automatically
call constructor/store.addInstanceReference
or constructor/store.addListReference.
constructor/store then retains the instance for the life
of the binding and ensures only single shared instance of a particular piece of data exists. This
prevents a common programming problem where multiple copies of an instance are held by parts of
an application that loaded the same data.
Other use
Integrating can-connect
with another framework is typically pretty easy. In general, the pattern involves creating
a behavior that integrates with your framework’s observable instances. The can/map behavior
can serve as a good guide. You’ll typically want to implement the following methods in your behavior:
.instance
- Creates the appropriate observable object type.
.list
- Creates the appropriate observable array type.
.serializeInstance
- Return a plain object out of the observable object type.
.serializeList
- Return a plain array out of the observable array type.
.createdInstance
- Update an instance with data returned from createData
.
.updatedInstance
- Update an instance with data returned from updateData
.
.destroyedInstance
- Update an instance with data returned from destroyData
.
.updatedList
- Update a list with raw data.
And, in most frameworks you know when a particular observable is being used, typically observed, and when it can be discarded. In those places, you should call:
- addInstanceReference — when an instance is being used.
- deleteInstanceReference — when an instance is no longer being used.
- addListReference — when a list is being used.
- deleteListReference — when a list is no longer being used.
Interfaces
The following is a list of the primary interface methods and properties implemented or consumed by the core behaviors.
Identifiers
.id( props | instance ) -> String
- Returns a unique identifier for the instance or raw data.
.queryLogic -> QueryLogic - A can-query-logic instance that defines the unique property and query behavior.
.listQuery(list) -> set- Returns the set a list represents.
.listQueryProp -> Symbol=can.listQuery` - The property on a List that contains its set.
Implemented by the base behavior.
Instance Interface
The following methods operate on instances and lists.
CRUD Methods
.getList(set) -> Promise<List>
- Retrieve a list of instances.
.getData(set) -> Promise<Instance>
- Retrieve a single instance.
.save(instance) -> Promise<Instance>
- Create or update an instance.
.destroy(instance) -> Promise<Instance>
- Destroy an instance.
Implemented by constructor/constructor behavior.
Overwritten by constructor/store and can/map behaviors.
Instance Callbacks
.createdInstance(instance, props)
- Called whenever an instance is created.
.updatedInstance(instance, props)
- Called whenever an instance is updated.
.destroyedInstance(instance, props)
- Called whenever an instance is destroyed.
.updatedList(list, updatedListData, set)
- Called whenever a list has been updated.
Implemented by constructor/constructor behavior.
Overwritten by real-time and constructor/callbacks-once behaviors.
Hydrators and Serializers
.instance(props) -> Instance
- Create an instance given raw data.
.list({data: Array<Instance>}) -> List
- Create a list given an array of instances.
.hydrateInstance(props) -> Instance
- Provide an instance given raw data.
.hydrateList({ListData}, set) -> List
- Provide a list given raw data.
.hydratedInstance(instance)
- Called whenever an instance is created in memory.
.hydratedList(list, set)
- Called whenever a list is created in memory.
.serializeInstance(instance) -> Object
- Return the serialized form of an instance.
.serializeList(list) -> Array<Object>
- Return the serialized form of a list and its instances.
Implemented by constructor/constructor behavior.
Overwritten by constructor/store and fall-through-cache behaviors.
Store Interface
.addInstanceReference(instance)
- Add a reference to an instance so that multiple copies can be avoided.
.deleteInstanceReference(instance)
- Remove a reference to an instance, freeing memory when an instance is no longer bound to.
.addListReference(list)
- Add a reference to a list so that multiple copies can be avoided.
.deleteListReference(list)
- Remove a reference to an list, freeing memory when a list is no longer bound to.
Implemented by the constructor/store behavior.
Data Interface
The raw-data connection methods.
CRUD Methods
.getListData(set) -> Promise<ListData>
- Retrieve list data.
.updateListData(listData[, set]) -> Promise<ListData>
- Update a list’s data.
.getSets() -> Promise<Array<Set>>
- Return the sets available to the connection, typically those stored in a cache connection.
.getData(params) -> Promise<Object>
- Retrieve data for a particular item.
.createData(props, cid) -> Promise<props>
- Create a data store record given the serialized form of the data. A
client ID is passed of the instance that is being created.
.updateData(props) -> Promise<props>
- Update a data store record given the serialized form of the data.
.destroyData(props) -> Promise<props>
- Delete a data store record given the serialized form of the data.
.clear() -> Promise
- Clear all data in the connection. Typically used to remove all data from a cache connection.
Implemented by data/url, data/localstorage-cache and data/memory-cache behaviors.
Overwritten by cache-requests, combine-requests and fall-through-cache behaviors.
Consumed by constructor/constructor behavior.
Data Callbacks
.gotListData(listData, set) -> ListaData
- Called whenever a list of data records are retrieved.
.gotData( props, params) -> props
- Called whenever an individual data record is retrieved.
.createdData( props, params, cid) -> props
- Called whenever an individual data record data is created.
.updatedData( props, params) -> props
- Called whenever an individual data record is updated.
.destroyedData( props, params) -> props
- Called whenever an individual data record is deleted.
Implemented by the data/callbacks behavior.
Overwritten by data/callbacks-cache and real-time behaviors.
Response Parsers
.parseListData(*) -> ListData
- Given the response of getListData, return required object format.
.parseInstanceData(*) -> props
- Given the response of getData, createData, updateData, and destroyData,
return the required object format.
Implemented by the data/parse behavior.
Real-time Methods
createInstance( props ) -> Promise<instance>
- Inform the connection a new data record has been created.
updateInstance( props ) -> Promise<instance>
- Inform the connection a data record has been updated.
destroyInstance( props ) -> Promise<instance>
- Inform the connection a data record has been destroyed.
Implemented by the real-time behavior.
Creating Behaviors
To create your own behavior, call connect.behavior
with the name of your behavior and a function that returns an
object that defines the hooks you want to overwrite or provide:
connect.behavior( "my-behavior", function( baseConnection ) {
return {
// Hooks here
};
} );
For example, creating a basic localStorage behavior might look like:
connect.behavior( "localstorage", function( baseConnection ) {
return {
getData: function( params ) {
const id = this.id( params );
return new Promise( function( resolve ) {
const data = localStorage.getItem( baseConnection.name + "/" + id );
resolve( JSON.parse( data ) );
} );
},
createData: function( props ) {
const id = localStorage.getItem( baseConnection.name + "-ID" ) || "0";
const nextId = JSON.parse( id ) + 1;
localStorage.setItem( baseConnection.name + "-ID", nextId );
return new Promise( ( resolve ) => {
props[ this.queryLogic.identityKeys()[0] ] = nextId;
localStorage.setItem( baseConnection.name + "/" + nextId, props );
resolve( props );
} );
},
updateData: function() { /* ... */ },
destroyData: function() { /* ... */ }
};
} );