I have an AngularJS application with ui router that consumes a REST API with Hypermedia. The general idea is to have the API generate the urls for its various calls, and keep the client from constructing the urls itself.
For example, when fetching the list of products, here's what the API returns:
[
{
"id": 1,
"name": "Product A",
"_links": {
"self": {
"href": "http://localhost:4444/api/products/1",
"name": null,
"templated": false
},
"actions": []
}
},
{
"id": 2,
"name": "Product B",
"_links": {
"self": {
"href": "http://localhost:4444/api/products/2",
"name": null,
"templated": false
},
"actions": []
}
}
]
So, the problem: I want to navigate to the detail of a product. I have the API url available from the collection hypermedia. However, upon changing states, I somehow need to pass this url to the detail state, so that the controller of the detail state can fetch the product.
The UI urls are completely decoupled from the API urls, i.e. the client has its own url scheme.
What's the best way to achieve this, all the while keeping the client stateless and each page bookmarkable?
One solution is to pass the url by ui router
's data
property. However, the page wouldn't be bookmarkable. Another way is to pass the API url in the UI url, which would keep the page bookmarkable (as long as the API url doesn't change), but the UI url would be very ugly.
Any other thoughts?
Unless I'm very wrong about this, I'm not looking for a templated solution, i.e. a solution where the API returns a template of a url that needs to be injected with parameters by the client. The whole point is that the url is already populated with data, as some urls are quite a bit more complicated than the example provided above.
I've encountered this problem a few times before. I've detailed my preferred solution step-by-step below. The last two steps are specifically for your problem outlined in your post, but the same principle can be applied throughout your application(s).
Start by defining a root endpoint on the API level. The corresponding root entity is a collection of top level links, in other words links to which the client(s) require direct access.
The idea is that the client only needs to know about one endpoint, namely the root endpoint. This has the advantages that you're not copying routing logic to the client and that versioning of the API becomes a lot easier.
Based on your example, this root endpoint could look like:
{
"_links": {
"products": {
"href": "http://localhost:4444/api/products",
}
}
}
Next define an abstract super state that resides at the top of your state hierarchy. I usually name this state main to avoid confusion with the root endpoint. The task of this super state is to resolve the root endpoint, like:
$stateProvider.state('main', {
resolve: {
root: function($http) {
return $http.get("http://localhost:4444/api/").then(function(resp){
return resp.data;
});
}
}
});
Then create a products state which is a descendant from the main state. Because the root endpoint is already resolved, we can use it in this state to get the link to the products API:
$stateProvider.state('products', {
parent: 'main',
url: "/products",
resolve: {
products: function($http, root) {
return $http.get(root._links.products.href).then(function(resp){
return resp.data;
});
}
}
});
Lastly, create a product detail state as a child of the products state above. Pass in the product's id via the $stateParams
(hence, it's part of the client URI) and use it to retrieve the correct product from the products array resolved in the parent:
$stateProvider.state('products.product', {
url: "/{productId}"
resolve: {
product: function($http, $timeout, $state, $stateParams, $q products) {
var productId = parseInt($stateParams.productId);
var product;
for (var i = 0; i < products.length; i++) {
product = products[i];
if (product.id === productId) {
return $http.get(product._links.self.href).then(function(response){
return response.data;
});
}
}
// go back to parent state if no child was found, do so in a $timeout to prevent infinite state reload
$timeout(function(){
$state.go('^', $stateParams);
});
// reject the resolve
return $q.reject('No product with id ' + productId);
}
});
You can move the code above into a service to make your states more lightweight.
Hope this helps!