Customizing Connections
Learn the layers that make up can-connect and how to implement custom connection functionality.
Introduction
CanJS provides several convenient ways of creating service layer interfaces (i.e connections) for your data models (i.e Lists & Maps). These include can-rest-model, can-realtime-rest-model and can-super-model. Underneath the surface, these are all just pre-defined sets of the building blocks of can-connect, behaviors.
These pre-defined sets of built-in behaviors can be configured to cover many use cases, but developers may need to modify their service integrations more extensively than what's possible with configuration alone. This could be to add new features or integrate with unusual backends not supported by the included behaviors. Deep customization of this sort is accomplished by creating connections that include new custom behaviors. Custom behaviors may extend or replace the functionality of built-in behaviors. Consequently, authors of custom behaviors need to have good knowledge of the functionality provided by existing behaviors and the points of interaction between them.
This guide covers the knowledge you need to write your own custom behaviors:
- how to implement behaviors, the layers of a connection
- the importance of behavior ordering
- the interfaces and their interface methods, the extension points used to implement behaviors
Interfaces Overview
Interfaces in can-connect refer to categorizations of related methods that may be implemented by a behavior. These methods define how the layers (behaviors) of can-connect interact with each other, and the public API of the connection. As an example, the can-connect "Data Interface" is made up of methods like the following:
Data Interface | |
---|---|
getData(query) |
Fetch the raw data of the persisted instance identified by the query. |
getListData(query) |
Fetch the raw data of the set of persisted instances identified by the query. |
createData(data) |
Make a request to persist the raw data in the passed argument. |
... and several others |
Essentially, interfaces are a loose specification of shared "extension points" that are used in the implementation of behaviors. For example, the following shows two behaviors that implement the same extension point differently; one using XHR, another using fetch
:
import { connect } from "//unpkg.com/can@6/core.mjs";
// two behaviors that implement the `getListData` method of the `Data Interface`
const xhrData = connect.behavior('xhr-data', (previousPrototype) => {
return {
getListData() {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.addEventListener("load", function() {
resolve(JSON.parse(this.responseText));
});
request.open("GET", this.url);
request.send();
});
}
// ... an actual DataInterface implementation would also implement:
// getData, createData, updateData, destroyData
};
});
const fetchData = connect.behavior(
'fetch-data',
(previousPrototype) => {
return {
getListData() {
return fetch(this.url).then(response => response.json());
}
// ... an actual DataInterface implementation would also implement:
// getData, createData, updateData, destroyData
};
}
);
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/',
};
const xhrConn = xhrData(connectionOptions);
const fetchConn = fetchData(connectionOptions);
xhrConn.getListData({}).then(data =>
console.log(`Used XHR to load ${data.length} todos.`)
);
fetchConn.getListData({}).then(data =>
console.log(`Used fetch to load ${data.length} todos.`)
);
Considering interfaces are just potential points of extension, an entire interface doesn't need to be implemented for a connection to function. For example, getQueries isn’t implemented by data/url
. Behaviors implement parts of an interface.
As an example of how interfaces are used as extension points, the Data Interface
defines ~20 methods, but the data/url
behavior only implements 5 of them. getListData
is one of those 5 methods and its implementation simply makes an HTTP request. The other behaviors in a connection might extend data/url
's getListData
, possibly to add request combining or request caching. Alternatively, other behaviors may implement methods from another interface which consume getListData
. A common scenario would be getList
from the Instance Interface
, whose implementation would call the getListData
implementation of this connection.
Behaviors Overview
Behaviors are the "layers" of can-connect connections. A connection is an object with a prototype chain made of behavior instances. The default export of behavior modules are "object extension functions" that add an instance of the behavior to the prototype chain of the passed object instance, and by chaining these functions, connections are built:
import { connect, QueryLogic } from "//unpkg.com/can@6/core.mjs";
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/{id}',
queryLogic: new QueryLogic({ identity: ["id"] }),
};
const baseInstance = connect.base(connectionOptions);
const dataUrlInstance = connect.dataUrl(base);
const connection = connect.dataCombineRequests(dataUrl);
connection.init();
// connection prototype chain is made up of the behavior instances
console.log(
`First proto: ${connection.__proto__ === dataUrlInstance}`
);
console.log(
`Second proto: ${connection.__proto__.__proto__ === baseInstance}`
);
console.log(
`Third proto: ${connection.__proto__.__proto__.__proto__ === connectionOptions}`
);
connection.getData({id: 5}).then((result) => {
console.log(`Fetched Todo JSON: `, result);
});
Illustrating the prototype chain in the example above:
Behaviors can be:
- Implementers: simply implementing interface methods
- Consumers: using interface methods provided by other behaviors
- Extenders: adding to other behavior's implementations of interface methods
To be a method implementer, a behavior just needs to include that method as part of their definition. The following implements getListData
:
import { connect } from "//unpkg.com/can@6/core.mjs";
// a behavior that implements the `getListData` method of the `Data Interface`
const fetchData = connect.behavior(
'fetch-data',
(previousPrototype) => {
return {
getListData() {
return fetch(this.url).then(response => response.json());
}
// ... a complete Data Interface implementation would also implement:
// getData, createData, updateData, destroyData
};
}
);
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/',
};
const fetchConn = fetchData(connectionOptions);
fetchConn.getListData({}).then(data =>
console.log(`Used fetch to load ${data.length} todos.`)
);
Being a method consumer just means calling a method on the connection. Here the behavior is consuming the getData
method:
import { ObservableObject, QueryLogic, connect } from "//unpkg.com/can@6/core.mjs";
class Todo extends ObservableObject {
static props = {
id: {
identity: true,
type: Number
},
userId: Number,
title: String,
completed: Boolean,
lastAccessedDate: String,
}
}
// a behavior that implements the `getList` method of the `Instance Interface`,
// consumes from `getListData`
const todoConstructor = connect.behavior(
'todo-constructor',
(previousPrototype) => {
return {
get(query) {
return this.getData(query).then((data) => new Todo(data));
}
// ... an actual Instance Interface implementation would also implement:
// getList, destroy, save & update
};
}
);
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/{id}',
queryLogic: new QueryLogic(Todo)
};
const connection = todoConstructor(connect.dataUrl(connect.base(
connectionOptions
)));
connection.init();
connection.get({id: 5}).then((result) => {
console.log(`Fetched A Todo: `, result);
});
Extending is a bit more complicated. Property access on a connection works like any ordinary JavaScript object, the property is first searched for on the "base" object instance, before searching up the objects on the base object's prototype chain. Since behaviors are instances on that chain, a behavior can override an implementation of a method from a behavior higher in the prototype chain. That feature, with behaviors being able to reference the prototype higher than them in the chain, is what allows them to extend interface methods. This is done by overriding a method and calling the previous implementation as part of that overriding implementation. The following extends getData
to add some logging:
import { ObservableObject, QueryLogic, connect } from "//unpkg.com/can@6/core.mjs";
class Todo extends ObservableObject {
static props = {
id: {
identity: true,
type: Number
},
userId: Number,
title: String,
completed: Boolean,
lastAccessedDate: String,
}
}
// a behavior that extends the `getData` method of the `Data Interface`, to add
// logging whenever a single instance is loaded
const loggingBehavior = connect.behavior(
'data-logging',
(previousPrototype) => {
return {
getData(query) {
return previousPrototype.getData(query).then((data) => {
console.log(
`Successfully fetched ${this.Map.shortName} ${this.id(data)} data from server.`
);
return data;
});
}
};
}
);
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/{id}',
queryLogic: new QueryLogic(Todo),
ObjectType: Todo,
};
const connection = connect.constructor(loggingBehavior(connect.dataUrl(connect.base(
connectionOptions
))));
connection.init();
connection.get({id: 5}).then((instance) => {
document.body.innerText = JSON.stringify(instance, null, 4);
});
Calling From Root vs Calling On Previous Behavior
As we've explained, extender and consumers both make calls to interface methods within the connection, but there's a distinction regarding how they make those calls that's worth highlighting. Typically when consumers call an interface method they call it on the connection object, which looks like this.getData()
. That causes a lookup for getData
starting at the "root" of the connection prototype chain. In contrast, when an extender calls the method they're extending, they call it on the behavior above them in the prototype chain e.g previousBehavior.getData()
. This causes a lookup on the portion of the prototype chain higher than the current behavior.
Interface Index
A listing of the interfaces of can-connect and their most commonly used methods:
Interface Name | Summary |
---|---|
Data |
The methods used by behaviors to get or mutate information in some form of persisted storage. These methods only operate on *raw* data comprised of plain JS Objects, Arrays and primitive types. The most common interface methods of the DataInterface are: |
Instance |
The methods used by behaviors to persist or mutate already persisted *typed* objects. The most common interface methods of the InstanceInterface are: |
Transition |
The methods used to transition between raw data and instances. The interface bridges the gap between the Data & Instance interfaces. The most common methods of this interface are:
|
Built-In Behavior Index
A listing of the behaviors provided by can-connect and a short description of their functionality:
Behavior Name | Summary |
---|---|
base
|
Provides option accessor and convenience methods required by other behaviors. Included in every connection. Not something that would typically be customized. |
cache-requests
|
Cache response data and use it for future requests. |
can-local-store
|
Implement a LocalStorage caching connection. |
can-memory-store
|
Implement an in-memory caching connection. |
can/constructor-hydrate
|
Always check the instanceStore when creating new instances of the connection `Map` type. |
can/map
|
Create `Map` or `List` instances from responses. Adds connection-aware convenience methods to configured types. |
can/ref
|
Handle references to other instances in the raw data responses. |
constructor
|
Manage persistence of 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. |
data/callbacks
|
Add callback hooks that are passed the results of the DataInterface request methods. |
data/callbacks-cache
|
Listen for `data/callbacks` and update the cache when requests complete. |
data/combine-requests
|
Combine overlapping or redundant requests |
data/parse
|
Convert response data into a format needed for other behaviors. |
data/url
|
Persist data to RESTful HTTP services. |
real-time
|
Lists updated when instances are created or deleted. |
Ordering Behaviors
When placing a custom behavior in the prototype chain it's important to know what interface methods you intend to implement and those you intend to extend. You may not be the only implementer of a method and for your implementation to be executed, your behavior will need to be lower in the prototype chain. When extending an interface method you may want your extension to run before all other extensions, after all extensions, or at some point between particular extensions. Behaviors lower in the chain execute before extensions higher in the chain.
The built-in behaviors of can-connect
have a canonical order to ensure they function. Understanding this order may help you better understand where your custom behavior should be ordered:
Behavior Name | Reason For Position |
---|---|
Note: Behaviors here are listed from highest in the prototype chain to lowest. |
|
base
|
Implements option accessor and convenience methods. Positioned highest in the prototype chain since this is basic "helper" functionality used to implement other behaviors. |
data/url
|
Implements the raw data manipulation methods of the `Data Interface`. Thus it needs to be placed higher than any behaviors that would consume or extend the data manipulation methods. |
data/parse
|
Extends the data manipulation methods of `Data Interface` to mutate the response received. Thus it needs to be lower in the proto chain than the implementation of those methods, but higher than any users of those methods. |
cache-requests
|
Extends the data fetching methods of `Data Interface` to modifies call to it, fulfilling the call from the cacheConnection if possible. Thus it needs to be lower in the proto chain than the implementation of those methods, but higher than any users of those methods. Positioned lower than `data/parse` so parsed data is cached.
|
combine-requests
|
Extends the data fetching methods of `Data Interface` to modifies call to it, combining requests if possible. Positioned lower than other extensions of the data fetching methods so their extensions to functionality benefit the combined request. |
data/worker
|
Implements the data fetching methods of the `Data Interface` to redirect calls to them to a worker thread. This behavior should come lower in the prototype chain than the behaviors extending the data fetching methods. This is so that the calls are redirected to the worker as early as possible, keeping all processing related to the data fetching methods isolated to the worker. |
constructor
|
Implements instance manipulation methods of the `Instance Interface`. Thus it needs to be placed higher than users of these methods. |
constructor/store
|
Extends the instance management methods of the `Instance Interface` modifying them to prevent recreating instances that are already actively used in the app, and provides references to active instances to other behaviors. Thus it's positioned lower than `constructor` but higher other behaviors which depend on those `Instance Interface` methods. |
can/map
|
Extends the instance management methods of the `Instance Interface` to integrate can-connect more tightly with CanJS `Map` & `List` types. Thus it's positioned lower than `constructor`. |
can/ref
|
Exposes connection functionality as an instantiable type that enables modeling of the relationships between persisted instance types. Expects CanJS instances to be created by the connection so this is positioned below `can/map`. |
fall-through-cache
|
Extends instance hydration and data fetching functionality to immediately return using data from a cache while simultaneously making a request, updating the instance when the request completes. This is positioned lower than `constructor` so that the `Instance Interface` hydration methods can be extended. |
real-time
|
Extends the instance creation methods so new or updated instances are added to existing Lists where appropriate. This is positoned lower in the prototype chain so that other `Instance Interface` extensions can be overridden to modify when certain actions execute.
Note:
|
data/callbacks-cache
|
Implements the `Data Interface` callbacks triggered by the `data/callbacks` behavior to keep a cache updated with changes to data. Typically positioned immediately above `data/callbacks` in the prototype chain so that it's the first behavior to react to changes to data via the callbacks. |
data/callbacks
|
Extends the `Data Interface` data fetching methods to know when modifications to data have taken place, in turn calling the `Data Interface` callbacks, notifying higher behaviors that an execution of a data fetching method has completed. This behavior is positioned very low in the prototype chain since the `Data Interface` callbacks are intended to run after a data fetching method is "complete" and no further behaviors extend them. |
constructor/callbacks-once
|
Extends the `Instance Interface` callback methods to prevent duplicated calls to them. This behavior is positioned very low in the prototype chain since the prevention of duplicate calls should take place as early as possible. |
Combining Behaviors
The combining of behaviors by chaining functions (as shown in the Behaviors Overview) is very straightforward but can be tedious to read when combining many behaviors. can-connect offers the connect
function to assemble behaviors, but to make it clearer to users how connections are assembled we now suggest that users deviating from the pre-built connections (e.g can-rest-model) assemble their behaviors themselves.
One way behaviors can be assembled cleanly is by using the reduce
method of arrays which iterates over the elements of an array, assembling a result:
Note: the
init
function must be called after creating the connection to initialize some behaviors. When using theconnect
function this is called automatically.
import { ObservableObject, QueryLogic, connect } from "//unpkg.com/can@6/core.mjs";
class Todo extends ObservableObject {
static props = {
id: {
identity: true,
type: Number
},
userId: Number,
title: String,
completed: Boolean,
lastAccessedDate: String,
}
}
const behaviors = [
connect.base,
connect.dataUrl,
connect.dataParse,
connect.constructor,
];
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/{id}',
queryLogic: new QueryLogic(Todo)
};
const connection = behaviors.reduce(
(connection, behavior) => behavior(connection),
connectionOptions
);
connection.init();
connection.get({id: 5}).then((result) => {
console.log(`Fetched A Todo: `, result);
});
Practical Custom Behavior Examples
As a demo of the concepts shown above, below are examples of custom behaviors that add functionality via behavior implementation, consumption, and extension.
Fetch-based data/url
The following is an implementer behavior that implements the same functionality as the data/url
behavior, but using the newer fetch API rather than the XHR API. This behavior may be useful if you want a feature fetch offers, like the ability to abort a request.
import { connect, key } from "//unpkg.com/can@6/core.mjs";
function _getRequestURLAndMethod(connUrl, requestName, params) {
let url = null;
let method = null;
if (connUrl[requestName]) {
[method, url] = connUrl[requestName].split(/ (.+)/);
} else {
switch(requestName) {
case 'getListData':
url = connUrl;
method = 'GET';
break;
case 'createData':
url = connUrl;
method = 'POST';
break;
case 'getData':
url = connUrl+"/{id}";
method = 'GET';
break;
case 'updateData':
url = connUrl+"/{id}";
method = 'PUT';
break;
case 'destroyData':
url = connUrl+"/{id}";
method = 'DELETE';
break;
}
}
url = key.replaceWith(url, params, (key, value) => encodeURIComponent(value), true);
return { url, method };
}
function _makeFetchRequest({ url, method }, params) {
return fetch(url, {
method,
credentials: 'same-origin',
body: method !== 'GET' ? JSON.stringify(params) : undefined,
headers: {
'Content-Type': 'application/json'
}
}).then(
response => {
if (response.ok) {
return response.json();
} else {
throw new Error(`Server did not produce a successful response code: ${response.status}`);
}
},
error => {
console.log('There has been a network error during fetch execution: ', error.message);
}
);
}
const fetchData = connect.behavior(
'fetch-data',
() => {
return {
getListData(params) {
return _makeFetchRequest(
_getRequestURLAndMethod(this.url, 'getListData', params),
params
);
},
getData(params) {
return _makeFetchRequest(
_getRequestURLAndMethod(this.url, 'getData', params),
params
);
},
createData(params) {
return _makeFetchRequest(
_getRequestURLAndMethod(this.url, 'createData', params),
params
);
},
updateData(params) {
return _makeFetchRequest(
_getRequestURLAndMethod(this.url, 'updateData', params),
params
);
},
destroyData(params) {
return _makeFetchRequest(
_getRequestURLAndMethod(this.url, 'destroyData', params),
params
);
},
};
}
);
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos',
};
const fetchConn = fetchData(connectionOptions);
fetchConn.getListData({}).then(data =>
console.log(`Used fetch to load ${data.length} todos.`)
);
WebSocket update channel
The following is a consumer behavior that uses the API of real-time
to allow real-time updates of models from a web socket connection. A behavior like this may be useful if your API allows you to subscribe via WebSocket to receive notifications of changes to models.
<script type="module">
import { connect, ObservableObject, QueryLogic, realtimeRestModel, stache, type } from "//unpkg.com/can@6/everything.mjs";
class Todo extends ObservableObject {
static get props() {
return {
id: {
identity: true,
type: type.maybeConvert(Number)
},
userId: type.maybeConvert(Number),
title: type.maybeConvert(String),
completed: type.maybeConvert(Boolean)
};
}
}
const websocketSidechannel = connect.behavior('websocket-sidechannel', (previousPrototype) => {
return {
init() {
// start listening to websocket when connection is built
this.websocket.onmessage(({data}) => {
// merge partial instance from websocket with existing data
const mergedData = Object.assign(this.instanceStore.get(data.id), data);
this.updateInstance(mergedData);
});
previousPrototype.init();
},
}
});
// an object taking the place of an open websocket for the purpose of this test
// receives a message updating a todo as completed every 3 seconds, starting
// from the first todo
const mockWebsocket = {
id: 1,
onmessage(listener) {
setInterval(() => {
// send a message completing a todo 3 seconds
listener({ data: { id: this.id, completed: true }});
this.id = this.id + 1;
}, 3000);
}
};
const template = stache(`
{{# for(todo of this.todos)}}
<li>{{todo.title}} - {{todo.completed}}</li>
{{/ for}}
`);
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/',
queryLogic: new QueryLogic(Todo),
ObjectType: Todo,
websocket: mockWebsocket,
};
const connection = websocketSidechannel(realtimeRestModel(connectionOptions));
connection.init();
connection.getList({}).then((todos) => {
document.getElementById('todos').appendChild(template({todos}));
});
</script>
<ul id="todos"></ul>
Auto-updating Field
The following is a extender behavior that extends the createdInstance
callbacks to update instances with a "last viewed" time.
import { connect, restModel, QueryLogic, ObservableObject } from "//unpkg.com/can@6/core.mjs";
class Todo extends ObservableObject {
static props = {
id: {
identity: true,
type: Number
},
userId: Number,
title: String,
completed: Boolean,
lastAccessedDate: String,
}
}
const updateLastAccessed = connect.behavior(
'update-last-accessed',
(previousBehavior) => {
function updateLastAccessed(instance) {
instance.lastAccessedDate = new Date().toISOString();
instance.save().then(() => console.log('Updated last accessed time for: ', instance));
};
return {
get() {
return previousBehavior.get.apply(this, arguments).then((instance) => {
updateLastAccessed(instance);
return instance;
});
},
getList() {
return previousBehavior.getList.apply(this, arguments).then((list) => {
list.forEach((instance) => updateLastAccessed(instance));
return list;
});
},
};
}
);
const connectionOptions = {
url: 'https://jsonplaceholder.typicode.com/todos/',
queryLogic: new QueryLogic(Todo),
ObjectType: Todo,
};
const connection = updateLastAccessed(restModel(connectionOptions));
connection.getList({}).then(data =>
console.log(`Loaded ${data.length} todos.`)
);
A Review of instance.save
Execution
To illustrate the interactions between a connection's behaviors we're going to trace the execution of the most typical way of persisting a model. That is calling the save
method on an instance of a CanJS Map that's been passed as the Map
option to a connection; in this case a connection created by can-rest-model. The behaviors in can-rest-model are:
We'll show how the instance methods of these behaviors interact to produce the final results and maintain the state of the connection:
Illustration Slides
Step-by-step Explanation
A user calls
save
on an instance of one of their can-connect'd models:class Todo extends ObservableObject { /* ... */ } Todo.connection = restModel({ ObjectType: Todo, ... }); const todoInstance = new Todo({ value: 'say hello to world' }); todoInstance.save().then( /* ... */ )
todoInstance.save()
returns a promise that resolves when all the connection's promise handlers for the request are completed (in step 9).The
save
method is not a default part of CanJS observable instances, rather it is added to the Todo prototype by thecan/map
behavior during the creation of the connection. The implementation ofsave
incan/map
calls thesave
method of the connection with the instance:connection.save(instance);
At this point the lowest behavior with an implementation of
InstanceInterface
save
in the connection prototype chain is called. In this case it's theconstructor
behavior. This implementation checks to see if the instance already has an identity value, which means it existed before this request. If it did already exist, an update request is made to the backend by calling theData Interface
methodupdateData
, otherwisecreateData
which happens in this case. The promise returned fromcreateData
has a handler added which will execute in step 7.createData
is called ondata/parse
. This behavior is an extension that reformats the response returned by the implementation ofcreateData
. It callscreateData
on the behaviors higher in the chain and attaches a promise handler which will execute in step 6.createData
is called ondata/url
. It makes a request to the server and returns a promise for the response data.Once the server responds, the promise handlers begin running. First to run is the one attached by
data/parse
, reformatting the response if appropriate.The next and last
createData
promise handler to run is the one attached byconstructor
. It calls the appropriateInstance Interface
callback eitherupdatedInstance
, or in this casecreatedInstance
.can/map
is the lowest behavior in the prototype chain that has acreatedInstance
callback, so it's called first. It updates the instance that was passed toconnection.save
with any new data in the response and emits acreated
event on the Map constructor.can/map
is an implementer ofcreatedInstance
not an extender, so at this pointcreatedInstance
callbacks are finished running.Now that the
createdInstance
callbacks initiated byconstructor
are finished, we resume the execution ofsave
promise handlers. The only remaining handlers are any attached to the promise returned frominstance.save()
. The connection execution is now complete and that user-facing promise is resolved.