├── .editorconfig ├── .gitignore ├── .jscsrc ├── .jshintrc ├── .travis.yml ├── AUTHORS.md ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bower.json ├── dist └── hypermedia.js ├── gulpfile.js ├── index.js ├── karma.conf.js ├── package.json └── src ├── _module.js ├── blobresource.js ├── blobresource.spec.js ├── context.js ├── context.spec.js ├── halresource.js ├── halresource.spec.js ├── resource.js ├── resource.spec.js ├── util.js ├── util.spec.js ├── vnderror.js └── vnderror.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | npm-debug.log 4 | 5 | build/ 6 | 7 | .idea 8 | *.iml 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "crockford", 3 | "validateIndentation": 2, 4 | "requireCurlyBraces": false, 5 | "requireMultipleVarDecl": false, 6 | "requireVarDeclFirst": false, 7 | "disallowDanglingUnderscores": false 8 | } 9 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globalstrict": true, 3 | "browser": true, 4 | "jasmine": true, 5 | "browserify": true, 6 | "globals": { 7 | "Dexie": false, 8 | "UriTemplate": false, 9 | "angular": false, 10 | "module": false, 11 | "inject": false, 12 | "linkHeaderParser": false, 13 | "mediaTypeParser": false 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | * [Joost Cassee](https://github.com/jcassee) 4 | * [Thomas Delnoij](https://github.com/mvcatsifma) 5 | * [Wido van den Burg](https://github.com/WidoBurg) 6 | * [Leo Blommers](https://github.com/losleos) 7 | 8 | This project was started at the Human Environment and Transport Inspectorate of 9 | the Dutch Ministry of Infrastructure and Environment. 10 | 11 | The example from the README was gratefully adapted from the 12 | [angular-hy-res](https://github.com/petejohanson/angular-hy-res) project by 13 | [Pete Johanson](https://github.com/petejohanson). 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.1 4 | 5 | - Make proper CommonJS module. (Sander Smeman) 6 | 7 | ## 1.0.0 8 | 9 | Mark package as stable. No functional changes. 10 | 11 | ## 0.10.2 12 | 13 | - Fix bug updating resource without embedded resources. 14 | 15 | ## 0.10.1 16 | 17 | - Make prototype properties writable to aid testing using mocks. 18 | 19 | ## 0.10.0 20 | 21 | - Add URI aliases. 22 | 23 | ## 0.9.0 24 | 25 | - Add `Resource.$refresh` and `Resource.$refreshPaths`. 26 | - Remove `Context.refresh`. 27 | 28 | ## 0.8.1 29 | 30 | - Fix gulp build. 31 | 32 | ## 0.8.0 33 | 34 | - Add `ts` argument to `Resource.$load` and `Resource.$loadPaths`. 35 | 36 | ## 0.7.6 37 | 38 | - Use `Resource.$merge` in `Context.httpPatch`. (Sergiy Pereverziev) 39 | 40 | ## 0.7.5 41 | 42 | - Fix tests. 43 | 44 | ## 0.7.4 45 | 46 | - Fix `package.json` for use as an npm module. 47 | 48 | ## 0.7.3 49 | 50 | - Annotate vnd.error module dependency injection. (Nikolay Gerzhan) 51 | 52 | ## 0.7.2 53 | 54 | - Fix bug in `Resource.$update` when using another resource object. 55 | 56 | ## 0.7.1 57 | 58 | - Build distribution. (Last release had old files in dist.) 59 | 60 | ## 0.7.0 61 | 62 | - Add extra properties in vnd.error objects. 63 | 64 | ## 0.6.0 65 | 66 | - Add Context.refresh. 67 | 68 | ## 0.5.0 69 | 70 | - Add `Resource.$isSynced`. 71 | 72 | ## 0.4.3 73 | 74 | - Do not remove properties starting with '$$' in $update. 75 | 76 | ## 0.4.2 77 | 78 | - Fix `BlobResource`. 79 | 80 | ## 0.4.1 81 | 82 | - Fix vnd.error media type. 83 | 84 | ## 0.4.0 85 | 86 | - Add support for error handlers. 87 | 88 | ## 0.3.0 89 | 90 | - Add self link to `Resource`. 91 | - Log non-existent paths in `Resource.$loadPaths`. 92 | - Fix `Resource.$patch`. 93 | 94 | ## 0.2.1 95 | 96 | - Fix bug with `HalResource` subclasses in embedded resources. 97 | 98 | ## 0.2.0 99 | 100 | - Add support for the PATCH method using JSON Merge Patch. 101 | - Add jshint. 102 | - Add jscs. 103 | 104 | ## 0.1.2 105 | 106 | - Build distribution. (Last release had old files in dist.) 107 | 108 | ## 0.1.1 109 | 110 | - Fix bug in profile setter. 111 | - Add tests. 112 | 113 | ## 0.1.0 114 | 115 | Initial release. 116 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Joost Cassee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hypermedia REST API client for AngularJS applications 2 | 3 | [](https://travis-ci.org/jcassee/angular-hypermedia) 4 | [](https://coveralls.io/github/jcassee/angular-hypermedia?branch=master) 5 | [](http://bower.io/search/?q=angular-hypermedia) 6 | [](https://www.npmjs.com/package/angular-hypermedia) 7 | [](https://www.npmjs.com/package/angular-hypermedia) 8 | [](https://github.com/jcassee/angular-hypermedia/blob/master/LICENSE.md) 9 | 10 | 11 | A hypermedia client for AngularJS applications. Supports relations in HTTP [Link 12 | headers](http://tools.ietf.org/html/rfc5988), JSON properties and 13 | [JSON HAL](http://tools.ietf.org/html/draft-kelly-json-hal), and resource 14 | [profiles](http://tools.ietf.org/html/rfc6906). 15 | 16 | An extension module 17 | [angular-hypermedia-offline](https://github.com/jcassee/angular-hypermedia-offline) 18 | is available that adds offline caching of resources. 19 | 20 | 21 | * [Installation](#installation) 22 | * [Quickstart](#quickstart) 23 | * [Provided services](#provided-services) 24 | * [Resources and contexts](#resources-and-contexts) 25 | * [GET, PUT, DELETE requests: state synchronization](#get-put-delete-requests-state-synchronization) 26 | * [PATCH requests: synchronization using JSON Merge Patch](#patch-requests-synchronization-using-json-merge-patch) 27 | * [POST requests](#post-requests) 28 | * [Relations](#relations) 29 | * [URI Templates](#uri-templates) 30 | * [Links](#links) 31 | * [Profiles](#profiles) 32 | * [Loading and refreshing resources](#loading-and-refreshing-resources) 33 | * [JSON HAL](#json-hal) 34 | * [Blob resources](#blob-resources) 35 | * [Error handlers](#error-handlers) 36 | 37 | 38 | ## Installation 39 | 40 | Install using Bower. 41 | 42 | bower install angular-hypermedia --save 43 | 44 | Then include it (and its dependencies) in your HTML page. 45 | 46 | 47 | 48 | 49 | 50 | 51 | Alternatively, install using NPM 52 | 53 | npm install angular-hypermedia --save 54 | 55 | ## Webpack integration 56 | 57 | You can use Webpack ProvidePlugin to integrate dependencies as follows: 58 | 59 | new webpack.ProvidePlugin({ 60 | mediaTypeParser: 'mediatype-parser', 61 | linkHeaderParser: 'linkheader-parser', 62 | UriTemplate: 'uri-templates' 63 | }), 64 | 65 | Since [mediatype-parser](https://github.com/jcassee/mediatype-parser) and [linkheader-parser](https://github.com/jcassee/linkheader-parser) releases `v0.1.2` you also need to add aliases to your Webpack configuration: 66 | 67 | resolve: { 68 | ... 69 | alias: { 70 | 'mediatype-parser': 'mediatype-parser/dist/mediatype-parser-node.js', 71 | 'linkheader-parser': 'linkheader-parser/dist/linkheader-parser-node.js' 72 | } 73 | 74 | ## Quickstart 75 | 76 | Consider a controller that lists [all GitHub notifications for the current 77 | user](https://developer.github.com/v3/activity/notifications/#list-your-notifications). 78 | It can use the `next` and `prev` links provided by [the GitHub API for 79 | pagination](https://developer.github.com/v3/#pagination). 80 | 81 | This could be an implementation of the controller: 82 | 83 | angular.module('myGitHubBrowser', ['hypermedia']) 84 | 85 | .controller('NotificationsController', function (ResourceContext) { 86 | $scope.page = null; 87 | 88 | new ResourceContext().get('https://api.github.com/notifications').then(function (page) { 89 | $scope.page = page; 90 | }); 91 | 92 | $scope.followRel = function (rel) { 93 | $scope.page.$linkRel(rel).$get().then(function (page) { 94 | $scope.page = page; 95 | }) 96 | }; 97 | 98 | $scope.hasRel = function (rel) { 99 | return rel in $scope.page.$links; 100 | }; 101 | }); 102 | 103 | The accompanying HTML template: 104 | 105 |
117 | 118 | 119 | ## Provided services 120 | 121 | To use this module, import `hypermedia` in your Angular module and inject any of 122 | the exported services. 123 | 124 | **Example:** 125 | 126 | angular.module('myApp', ['hypermedia']) 127 | .factory('MyController', function (ResourceContext, Resource, HalContext, BlobContext) { 128 | ... 129 | }); 130 | 131 | 132 | ## Resources and contexts 133 | 134 | This module assumes that a hypermedia API client often interacts with 135 | multiple related resources for the functionality provided by a page. The 136 | `ResourceContext` is responsible for keeping together resources that are being 137 | used together. Resources are bound to a single context. 138 | 139 | Resources are represented by a `Resource` or one of its subclasses. A resource 140 | is a unit of data that can be synchronized with its authoritative source using 141 | HTTP requests. In this way, it is similar to a AngularJS `$resource` instance. 142 | 143 | **Example:** 144 | 145 | var context = new ResourceContext(); 146 | var person = context.get('http://example.com/composer/john'); 147 | expect(person.$uri).toBe('http://example.com/composer/john'); 148 | 149 | The context acts like an identity map, in the sense that calling `context.get` 150 | with the same URI returns the same `Resource` object. 151 | 152 | If a subclass of `Resource` is required, a second argument may be used. 153 | 154 | **Example:** 155 | 156 | var movie = context.get('http://example.com/movie/jaws', HalResource); 157 | 158 | If you are using an API that is based on a media type for which a Resource 159 | subclass exists (JSON HAL, for example) it is useful to create a context with a 160 | default factory. 161 | 162 | **Example:** 163 | 164 | var context2 = new ResourceContext(HalResource); 165 | var movie2 = context.get('http://example.com/movie/jaws'); 166 | 167 | 168 | ## GET, PUT, DELETE requests: state synchronization 169 | 170 | Resources are synchronized using GET, PUT and DELETE requests. The methods on 171 | the resource object are `$get`, `$put` and `$delete` respectively. These 172 | methods return a promise that is resolved with the `Resource` when the request 173 | completes successfully. 174 | 175 | **Example:** 176 | 177 | context.get('http://example.com/composer/john').$get().then(function (composer) { 178 | expect(composer.firstName).toBe('John'); 179 | expect(composer.lastName).toBe('Williams'); 180 | }); 181 | 182 | person.email = 'john@example.com'; 183 | person.favoriteMovie = 'Close Encounters'; 184 | person.$put().then(function () { 185 | console.log('success!'); 186 | }); 187 | 188 | 189 | ## PATCH requests: synchronization using JSON Merge Patch 190 | 191 | The PATCH request method updates a resources by only sending a "diff" of the 192 | state. `Resource` uses [JSON Merge Patch](https://tools.ietf.org/html/rfc7386). 193 | It is a very simple JSON patch format suitable for describing modifications to 194 | JSON documents that primarily use objects for their structure and do not make 195 | use of explicit `null` values. Subclasses of `Resource` may choose to support 196 | other formats by overriding the `$patchRequest` method. 197 | 198 | The `$patch` method accepts a mapping of (new or existing) properties to updated 199 | values; mapping a property to `null` will delete the property. Objects are 200 | merged recursively, but arrays are replaced. The algorithm is [specified in the 201 | RFC](https://tools.ietf.org/html/rfc7386#section-2). The changes are applied to 202 | the `Resource` object after the HTTP request has completed successfully; if it 203 | fails the object remains unchanged. 204 | 205 | **Example:** 206 | 207 | person.$patch({email: 'johnwilliams@example.com', favoriteMovie: null}).then(function () { 208 | console.log('email changed to ' + person.email); 209 | }, function () { 210 | console.log('request failed, email is still ' + person.email); 211 | }); 212 | 213 | 214 | ## POST requests 215 | 216 | A POST request is used to "operate on" data instead of synchronizing it. What 217 | the "operate" means is up to the server, and depends on the resource. It is 218 | often used to create new resources. The `$post` method accepts as arguments the 219 | data to be sent in the body and a mapping of headers. 220 | 221 | **Example:** 222 | 223 | person.$post({password: 'secret'}, {'Content-Type': 'text/plain'}).then(function () { 224 | console.log('password changed'); 225 | }); 226 | 227 | 228 | ## Relations 229 | 230 | The essence of hypermedia is the linking of resources. In its simplest form, 231 | a resource can link to another resource by including its URI as a property. 232 | Because a reference to another resource is a hypermedia reference, such a 233 | property is sometimes called an "href". 234 | 235 | Note: a relation can be a string or an array of string. 236 | 237 | **Example:** 238 | 239 | person.carHref = 'http://example.com/car/mercedes-sedan'; 240 | person.friendHrefs = [ 241 | 'http://example.com/director/george', 242 | 'http://example.com/director/steven' 243 | ]; 244 | 245 | Of course, it is possible to look up URIs in the context, but `Resource` has the 246 | convenience method `$propRel` for getting related resources. If the property 247 | value is an array of URIs then an array of resources is returned. 248 | 249 | **Example:** 250 | 251 | var car = person.$propRel('carHref'); 252 | var friends = person.$propRel('friendHrefs'); 253 | 254 | If the target resource is not created using the default context factory, you can 255 | add the factory as the last parameter. 256 | 257 | **Example:** 258 | 259 | car.manufacturerHref = 'http://example.com/hal/companies/mercedes'; 260 | var manufacturer = car.$propRel('manufacturerHref', HalResource) 261 | 262 | 263 | ## URI Templates 264 | 265 | A reference can also be a [URI Template](http://tools.ietf.org/html/rfc6570), 266 | containing parameters that need to be substituted before it can be resolved. The 267 | `$propRel` accepts a second argument of variables (a mapping of names to values) 268 | to resolve a URI Template reference. 269 | 270 | **Example:** 271 | 272 | person.appointmentsHref = 'http://example.com/appointments/john/{date}' 273 | var todaysAppointments = person.$propRel('appointmentsHref', {date: '2015-03-05'}); 274 | 275 | URI Template variables and resource factory can be specified at the same time. 276 | 277 | **Example:** 278 | 279 | manufacturer.modelsHref = 'http://example.com/hal/companies/mercedes/models{?current}' 280 | var currentModels = manufacturer.$propRel('modelsHref', {current: true}, HalResource); 281 | 282 | 283 | ## Links 284 | 285 | Instead of referencing other resources in properties, it is also possible to use 286 | links. Links are returned by the server as 287 | [Link headers](http://tools.ietf.org/html/rfc5988). 288 | 289 | The `$links` property is a mapping of relations to link objects. A link object 290 | has an `href` property containing the relation target URI. Other properties are 291 | link attributes as [listed in the RFC](http://tools.ietf.org/html/rfc5988). 292 | 293 | Relations are either keywords from the [IANA 294 | list](http://www.iana.org/assignments/link-relations/link-relations.xhtml) or 295 | URIs. (These URIs are used as references, but may point to documentation that 296 | describes the relationship.) 297 | 298 | **Example:** 299 | 300 | car.$links['http://example.com/rels/owner'] = { 301 | href: 'http://example.com/composer/john', 302 | title: 'Owner' 303 | }; 304 | 305 | Link relations are followed in much the same way as property relations, using 306 | the `$linkRel` method. 307 | 308 | **Example:** 309 | 310 | expect(car.$linkRel('http://example.com/rels/owner')).toBe(person); 311 | 312 | 313 | ### Self links and URI aliases 314 | 315 | The *self* relation identifies the resource itself. If a HTTP GET response 316 | contains a self link to a URI different from resource URI, that URI is added to 317 | the context as an alias. In other words, the resource will be available under 318 | both the original URI and the self href. 319 | 320 | **Example:** 321 | 322 | var movie = context.get('http://example.com/movie/jaws-4'); 323 | movie.$get().then(function () { 324 | // GET response contains self link to "http://example.com/movie/jaws-the-revenge" 325 | expect(context.get('http://example.com/movie/jaws-the-revenge')).toBe(movie); 326 | }); 327 | 328 | This behavior can be disabled by setting `context.enableAliases` to `false`, or 329 | globally by setting `ResourceContext.defaultEnableAliases`. If aliases are 330 | disabled, trying to update a resource with a self link different to the resource 331 | URI will throw an error. 332 | 333 | 334 | ## Profiles 335 | 336 | Resources can often be said to be of a certain type, in the sense that in the 337 | examples, the resource referenced by `http://example.com/composer/john` "is a 338 | person". This is called a [profile](http://tools.ietf.org/html/rfc6906). 339 | Profiles are identified by a URI. (As with relations, they may double as a 340 | pointer to the profile documentation.) Resources have a `$profile` property 341 | containing the profile URI. 342 | 343 | It is possible to add functionality to resources of specific profiles by 344 | registering properties. Setting `$profile` immediately applies the properties 345 | registered with that profile. (The properties are set on a per-resource 346 | prototype, so they do not interfere with the resource data and are removed when 347 | the profile is removed.) 348 | 349 | Note: if using an array, adding profiles to the array after setting `$profile` 350 | will not update the properties. 351 | 352 | Profiles are registered using `Resource.registerProfile(profile, properties)` 353 | or `Resource.registerProfiles(profileProperties)`. Properties are applied to 354 | resources using `Object.defineProperties`. 355 | 356 | **Example:** 357 | 358 | Resource.registerProfiles({ 359 | 'http://example.com/profiles/person': { 360 | fullName: {get: function () { 361 | return this.firstName + ' ' + this.lastName; 362 | }}, 363 | 364 | car: {get: function () { 365 | return this.propRel('carHref'); 366 | }} 367 | } 368 | }); 369 | 370 | person.$profile = 'http://example.com/profiles/person'; 371 | 372 | expect(person.fullName).toBe('John Williams'); 373 | expect(person.car.brand).toBe('Mercedes'); 374 | 375 | The profile is automatically set if the response of a GET request contains 376 | either a profile link or the profile parameter in the Content-Type header. 377 | 378 | 379 | ## Loading and refreshing resources 380 | 381 | Because different relations may point to the same URI, just calling `$get` on 382 | all followed resources risks issuing GET requests for the same resource multiple 383 | times. By using `$load` instead of `$get` a GET request will only be issued if 384 | the resource was not already synchronized with the server. 385 | 386 | **Example:** 387 | 388 | person.$links['http://example.com/rels/artistic-works'] = 'http://example.com/composers/john/works' 389 | person.$linkRel('http://example.com/rels/artistic-works').$load().then(function (works) { 390 | console.log("John's works: " + works.map(function (work) { return work.title; }).join(', ')); 391 | }); 392 | 393 | When using resources in Angular views, it is important that all information 394 | needed to render the template has been loaded. Often, this means loading all 395 | resources that are reached by following a specific path through the resource 396 | relations. The `$loadPaths` method loads all resources reached by follow 397 | relation paths. The argument is a nested object hierarchy where the keys 398 | represent link or property relations, or computed properties that return other 399 | resources directly (such as the `car` profile property in the examples). 400 | 401 | **Example:** 402 | 403 | person.$loadPaths({ 404 | car: {}, 405 | friendHrefs: { 406 | car: {} 407 | }, 408 | 'http://example.com/rels/artistic-works': {} 409 | }); 410 | 411 | Loading related resources is usually done in resolve functions of a URL route. 412 | 413 | **Example:** 414 | 415 | $routeProvider.when('/composers', { 416 | templateUrl: 'composers.html', 417 | controller: 'ComposersController', 418 | resolve: { 419 | composers: function (ResourceContext) { 420 | var context = new ResourceContext(); 421 | return context.get('http://example.com/composers').$loadPaths({ 422 | item: { 423 | car: {}, 424 | friendHrefs: { 425 | car: {} 426 | } 427 | } 428 | }); 429 | } 430 | } 431 | }); 432 | 433 | It is often useful to make sure the resource data is not too old. You can pass a 434 | timestamp to the `$load` and `$loadPaths` methods to issue a GET request if the 435 | last synchronization was before that time. The `$refresh` and 436 | `$refreshPaths` methods work similarly, but use `Date.now()` as a default 437 | timestamp. 438 | 439 | **Example:** 440 | 441 | var oneHourAgo = Date.now() - 60*60*1000; 442 | movie.$load(oneHourAgo); 443 | person.$loadPaths({car: {}}, oneHourAgo); 444 | 445 | car.$refresh(); 446 | manufacturer.$refreshPaths({modelsHref: {}}) 447 | 448 | 449 | ## JSON HAL 450 | 451 | The 452 | [JSON Hypertext Application Language](https://tools.ietf.org/html/draft-kelly-json-hal) 453 | is a JSON-based media type that reserves properties to include links and 454 | embedded resources. `HalResource` is a subclass of `Resource` that understands 455 | these properties. It accepts the `application/hal+json` media type, but will use 456 | `application/json` for PUT requests. The idea is that links are API wiring, and 457 | not application state. 458 | 459 | On GET requests, links are copied from the `_links` property and embedded 460 | resources are extracted from `_embedded` and added to the context. Both 461 | properties are then deleted. 462 | 463 | Using `$load` and `$loadPaths` makes sense especially with HAL, as this makes 464 | the client robust with regard to the presence or absence of embedded resources. 465 | 466 | **Example:** 467 | 468 | var root = new ResourceContext(HalResource).get('http://example.com/hal'); 469 | root.$loadPaths({ 470 | 'ex:manufacturers': { 471 | 'items': { 472 | 'ex:models: { 473 | 'items': {} 474 | }, 475 | 'ex:subsidiaries': { 476 | 'items': {} 477 | } 478 | } 479 | } 480 | }); 481 | 482 | 483 | ## Blob resources 484 | 485 | A `BlobResource` can be used to represent binary data. The data received from 486 | the server will be stored as a `Blob` in the `data` property of the object. 487 | 488 | **Example:** 489 | 490 | person.profilePhotoHref = 'http://example.com/photos/johnwilliams.jpg'; 491 | person.$propRel('profilePhotoHref', BlobResource).$load().then(function (photo) { 492 | $scope.photoImgSrc = $window.URL.createObjectURL(resource.data); 493 | }); 494 | 495 | 496 | ## Error handlers 497 | 498 | Many APIs will use the body of a 4xx or 5xx response to inform the client of 499 | the type of error. An error media type, such as 500 | [vnd.error](https://github.com/blongden/vnd.error), can be used as a formal 501 | description of the problem. The `ResourceContext` HTTP methods can automatically 502 | convert such responses to an `error` property on the response result. 503 | 504 | The vnd.error media type is supported automatically. You can register handlers 505 | for other media types: 506 | 507 | **Example:** 508 | 509 | ResourceContext.registerErrorHandler('text/plain', function (response) { 510 | return {message: response.data}; 511 | }); 512 | 513 | A handler must return an object with a `message` property containing a 514 | human-readable error message. It may add other properties. For example, the 515 | handler for vnd.error will add the following properties: 516 | 517 | * `message`: the error message 518 | * `logref`: an error identifier 519 | * `path`: a pointer to the JSON field relevant to the error 520 | * `$links`: hyperlinks to error metadata 521 | * `$nested`: embedded error objects 522 | 523 | The context will return the error object as the `error` property of the 524 | rejection response. If no response body is returned or the media type has not 525 | been registered, `response.error.message` is set to the HTTP response status message. 526 | 527 | **Example:** 528 | 529 | person.$get().catch(function (response) { 530 | console.log('Error: ' + response.error.message); 531 | console.log('Logref: ' + response.error.logref); 532 | console.log('Path: ' + response.error.path); 533 | if (response.error.$nested) { 534 | response.error.$nested.forEach(function (error) { 535 | console.log('Nested error: ' + error.message; 536 | }); 537 | } 538 | }); 539 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-hypermedia", 3 | "description": "Hypermedia REST API client for AngularJS applications", 4 | "homepage": "https://github.com/jcassee/angular-hypermedia", 5 | "license": "MIT", 6 | "main": "dist/hypermedia.js", 7 | "ignore": [ 8 | ".editorconfig", 9 | ".gitignore", 10 | ".travis.yml" 11 | ], 12 | "dependencies": { 13 | "angular": "^1.3", 14 | "uri-templates": "^0.1", 15 | "linkheader-parser": "^0.1", 16 | "mediatype-parser": "^0.1" 17 | }, 18 | "devDependencies": { 19 | "angular-mocks": "^1.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /dist/hypermedia.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc module 5 | * @name halresource 6 | * @version 1.0.1 7 | * @description 8 | * 9 | * This module contains classes and services to work with hypermedia APIs. 10 | */ 11 | angular.module('hypermedia', []); 12 | 13 | 'use strict'; 14 | 15 | angular.module('hypermedia') 16 | 17 | /** 18 | * @ngdoc type 19 | * @name BlobResource 20 | * @description 21 | * 22 | * Resource containing binary data. 23 | */ 24 | .factory('BlobResource', ['Resource', 'HypermediaUtil', function (Resource, HypermediaUtil) { 25 | 26 | /** 27 | * Resource with a media type and some data. 28 | * 29 | * @constructor 30 | * @param {string} uri the resource URI 31 | * @param {ResourceContext} context the context object 32 | */ 33 | function BlobResource(uri, context) { 34 | var instance = Resource.call(this, uri, context); 35 | 36 | /** 37 | * The resource data. 38 | * 39 | * @type {Blob} 40 | */ 41 | instance.data = ''; 42 | 43 | return instance; 44 | } 45 | 46 | // Prototype properties 47 | BlobResource.prototype = Object.create(Resource.prototype, { 48 | constructor: {value: BlobResource} 49 | }); 50 | 51 | HypermediaUtil.defineProperties(BlobResource.prototype, { 52 | /** 53 | * Create a $http GET request configuration object. 54 | * 55 | * @function 56 | * @returns {object} 57 | */ 58 | $getRequest: {value: function () { 59 | return { 60 | method: 'get', 61 | url: this.$uri, 62 | headers: {'Accept': '*/*'}, 63 | responseType: 'blob', 64 | addTransformResponse: function (data) { 65 | return {data: data}; 66 | } 67 | }; 68 | }}, 69 | 70 | /** 71 | * Create a $http PUT request configuration object. 72 | * 73 | * @function 74 | * @returns {object} 75 | */ 76 | $putRequest: {value: function () { 77 | return { 78 | method: 'put', 79 | url: this.$uri, 80 | data: this.data, 81 | headers: {'Content-Type': this.data.type || 'binary/octet-stream'} 82 | }; 83 | }}, 84 | 85 | /** 86 | * Throw an error. Binary resources have no obvious PATCH semantics. 87 | */ 88 | $patchRequest: {value: function () { 89 | throw new Error('BlobResource does not support the PATCH method'); 90 | }} 91 | }); 92 | 93 | return BlobResource; 94 | }]) 95 | 96 | ; 97 | 98 | 'use strict'; 99 | 100 | angular.module('hypermedia') 101 | 102 | /** 103 | * @ngdoc type 104 | * @name ResourceContext 105 | * @description 106 | * 107 | * Context for working with hypermedia resources. The context has methods 108 | * for making HTTP requests and acts as an identity map. 109 | */ 110 | .factory('ResourceContext', ['$http', '$log', '$q', 'Resource', 'HypermediaUtil', function ($http, $log, $q, Resource, HypermediaUtil) { 111 | 112 | var busyRequests = 0; 113 | var errorHandlers = {}; 114 | 115 | /** 116 | * Resource context. 117 | * 118 | * @constructor 119 | * @param {ResourceFactory} [resourceFactory] 120 | */ 121 | function ResourceContext(resourceFactory) { 122 | this.resourceFactory = resourceFactory || ResourceContext.defaultResourceFactory; 123 | this.resources = {}; 124 | } 125 | 126 | Object.defineProperties(ResourceContext, { 127 | 128 | /** 129 | * The default resource factory. 130 | * 131 | * @property {resourceFactory} 132 | */ 133 | defaultResourceFactory: {value: Resource, writable: true}, 134 | 135 | /** 136 | * The number of current HTTP requests. 137 | * 138 | * @property {number} 139 | */ 140 | busyRequests: {get: function () { 141 | return busyRequests; 142 | }}, 143 | 144 | registerErrorHandler: {value: function (contentType, handler) { 145 | errorHandlers[contentType] = handler; 146 | }}, 147 | 148 | /** 149 | * Whether resource aliases are allowed by default. 150 | */ 151 | defaultEnableAliases: {value: true, writable: true} 152 | }); 153 | 154 | ResourceContext.prototype = Object.create(Object.prototype, { 155 | constructor: {value: ResourceContext} 156 | }); 157 | 158 | HypermediaUtil.defineProperties(ResourceContext.prototype, { 159 | /** 160 | * Get the resource for an URI. Creates a new resource if not already in the context. 161 | * 162 | * @function 163 | * @param {string} uri 164 | * @param {ResourceFactory} [Factory] optional resource creation function 165 | * @returns {Resource} 166 | */ 167 | get: {value: function (uri, Factory) { 168 | var resource = this.resources[uri]; 169 | if (!resource) { 170 | Factory = (Factory || this.resourceFactory); 171 | if (!Factory) throw new Error('No resource factory: ' + uri); 172 | resource = this.resources[uri] = new Factory(uri, this); 173 | } 174 | return resource; 175 | }}, 176 | 177 | /** 178 | * Copy a resource into this context. 179 | * 180 | * @function 181 | * @param {Resource} resource 182 | * @returns {Resource} a copy of the resource in this context 183 | */ 184 | copy: {value: function (resource) { 185 | var copy = this.get(resource.$uri); 186 | copy.$update(resource, resource.$links); 187 | return copy; 188 | }}, 189 | 190 | /** 191 | * Whether resaurce aliases are enabled. If false, context.addAlias throws an error. 192 | */ 193 | enableAliases: {value: ResourceContext.defaultEnableAliases, writable: true}, 194 | 195 | /** 196 | * Adds an alias to an existing resource. 197 | * 198 | * @function 199 | * @param {string} aliasUri the new URI to point to the original resource 200 | * @param {string} originalUri the URI of the original resource. 201 | */ 202 | addAlias: {value: function (aliasUri, originalUri) { 203 | if (!this.enableAliases) throw new Error('Resource aliases not enabled'); 204 | this.resources[aliasUri] = this.resources[originalUri]; 205 | }}, 206 | 207 | /** 208 | * Perform a HTTP GET request on a resource. 209 | * 210 | * @function 211 | * @param {Resource} resource 212 | * @returns a promise that is resolved to the resource 213 | * @see Resource#$getRequest 214 | */ 215 | httpGet: {value: function (resource) { 216 | var self = this; 217 | busyRequests += 1; 218 | var request = updateHttp(resource.$getRequest()); 219 | return $http(request).then(function (response) { 220 | var links = parseLinkHeader(response.headers('Link')); 221 | 222 | // Convert media type profile to profile link 223 | var mediaType = mediaTypeParser.parse(response.headers('Content-Type')); 224 | if (!('profile' in links) && 'profile' in mediaType.params) { 225 | links.profile = {href: mediaType.params.profile}; 226 | } 227 | 228 | var updatedResources = resource.$update(response.data, links); 229 | return self.markSynced(updatedResources, Date.now()); 230 | }, handleErrorResponse).then(function () { 231 | return resource; 232 | }).finally(function () { 233 | busyRequests -= 1; 234 | }); 235 | }}, 236 | 237 | /** 238 | * Perform a HTTP PUT request. 239 | * 240 | * @function 241 | * @param {Resource} resource 242 | * @returns a promise that is resolved to the resource 243 | * @see Resource#$putRequest 244 | */ 245 | httpPut: {value: function (resource) { 246 | var self = this; 247 | busyRequests += 1; 248 | var request = updateHttp(resource.$putRequest()); 249 | return $http(request).then(function () { 250 | return self.markSynced(resource, Date.now()); 251 | }, handleErrorResponse).then(function () { 252 | return resource; 253 | }).finally(function () { 254 | busyRequests -= 1; 255 | }); 256 | }}, 257 | 258 | /** 259 | * Perform a HTTP PATCH request. 260 | * 261 | * @function 262 | * @param {Resource} resource 263 | * @returns a promise that is resolved to the resource 264 | * @see Resource#$patchRequest 265 | */ 266 | httpPatch: {value: function (resource, data) { 267 | var self = this; 268 | busyRequests += 1; 269 | var request = updateHttp(resource.$patchRequest(data)); 270 | return $http(request).then(function () { 271 | resource.$merge(request.data); 272 | return self.markSynced(resource, Date.now()); 273 | }, handleErrorResponse).then(function () { 274 | return resource; 275 | }).finally(function () { 276 | busyRequests -= 1; 277 | }); 278 | }}, 279 | 280 | /** 281 | * Perform a HTTP DELETE request and unmark the resource as synchronized. 282 | * 283 | * @function 284 | * @param {Resource} resource 285 | * @returns a promise that is resolved to the resource 286 | * @see Resource#$deleteRequest 287 | */ 288 | httpDelete: {value: function (resource) { 289 | var self = this; 290 | busyRequests += 1; 291 | var request = updateHttp(resource.$deleteRequest()); 292 | return $http(request).then(function () { 293 | delete self.resources[resource.$uri]; 294 | return self.markSynced(resource, null); 295 | }, handleErrorResponse).then(function () { 296 | return resource; 297 | }).finally(function () { 298 | busyRequests -= 1; 299 | }); 300 | }}, 301 | 302 | /** 303 | * Perform a HTTP POST request. 304 | * 305 | * @function 306 | * @param {Resource} resource 307 | * @param {*} data request body 308 | * @param {object} [headers] request headers 309 | * @param {ConfigHttp} [callback] a function that changes the $http request config 310 | * @returns a promise that is resolved to the response 311 | * @see Resource#$postRequest 312 | */ 313 | httpPost: {value: function (resource, data, headers, callback) { 314 | busyRequests += 1; 315 | var request = updateHttp(resource.$postRequest(data, headers, callback)); 316 | return $http(request).catch(handleErrorResponse).finally(function () { 317 | busyRequests -= 1; 318 | }); 319 | }}, 320 | 321 | /** 322 | * Mark a resource as synchronized with the server. 323 | * 324 | * @function 325 | * @param {Resource|Resource[]} resources 326 | * @param {number} syncTime the timestamp of the last synchronization 327 | * @returns a promise that is resolved when the resources have been marked 328 | * @see Resource#syncTime 329 | */ 330 | markSynced: {value: function (resources, syncTime) { 331 | resources = angular.isArray(resources) ? resources : [resources]; 332 | resources.forEach(function (resource) { 333 | resource.$syncTime = syncTime; 334 | }); 335 | return $q.when(); 336 | }} 337 | }); 338 | 339 | return ResourceContext; 340 | 341 | 342 | function appendTransform(defaults, transform) { 343 | if (!transform) return defaults; 344 | defaults = angular.isArray(defaults) ? defaults : [defaults]; 345 | return defaults.concat(transform); 346 | } 347 | 348 | function updateHttp(config) { 349 | config.transformRequest = appendTransform($http.defaults.transformRequest, config.addTransformRequest); 350 | config.transformResponse = appendTransform($http.defaults.transformResponse, config.addTransformResponse); 351 | return config; 352 | } 353 | 354 | function parseLinkHeader(header) { 355 | return header ? linkHeaderParser.parse(header) : {}; 356 | } 357 | 358 | function handleErrorResponse(response) { 359 | var contentType = response.headers('Content-Type'); 360 | var handler = errorHandlers[contentType]; 361 | response.error = (handler ? handler(response) : {message: response.statusText}); 362 | return $q.reject(response); 363 | } 364 | }]) 365 | 366 | ; 367 | 368 | /** 369 | * A callback function used by the context to create resources. Will be called 370 | * with the 'new' operator, so can be a constructor. 371 | * 372 | * @callback ResourceFactory 373 | * @returns {Resource} the created resource 374 | * @see ResourceContext 375 | */ 376 | 377 | 'use strict'; 378 | 379 | angular.module('hypermedia') 380 | 381 | /** 382 | * @ngdoc type 383 | * @name HalResource 384 | * @description 385 | * 386 | * HAL resource. 387 | */ 388 | .factory('HalResource', ['$log', 'HypermediaUtil', 'Resource', function ($log, HypermediaUtil, Resource) { 389 | var forArray = HypermediaUtil.forArray; 390 | 391 | /** 392 | * HAL resource. 393 | * 394 | * @constructor 395 | * @param {string} uri the resource URI 396 | * @param {ResourceContext} context the context object 397 | */ 398 | function HalResource(uri, context) { 399 | return Resource.call(this, uri, context); 400 | } 401 | 402 | // Prototype properties 403 | HalResource.prototype = Object.create(Resource.prototype, { 404 | constructor: {value: HalResource} 405 | }); 406 | 407 | HypermediaUtil.defineProperties(HalResource.prototype, { 408 | /** 409 | * Create a $http GET request configuration object. 410 | * 411 | * @function 412 | * @returns {object} 413 | */ 414 | $getRequest: {value: function () { 415 | return { 416 | method: 'get', 417 | url: this.$uri, 418 | headers: {'Accept': 'application/hal+json'} 419 | }; 420 | }}, 421 | 422 | /** 423 | * Update the resource with new data. 424 | * 425 | * @function 426 | * @param {object} data 427 | * @param {object} [links] 428 | * @returns all updated resources 429 | */ 430 | $update: {value: function (data, links) { 431 | links = links || {}; 432 | return extractAndUpdateResources(data, links, this, this); 433 | }} 434 | }); 435 | 436 | return HalResource; 437 | 438 | 439 | /** 440 | * Recursively extract embedded resources and update them in the context, then update the resource itself. 441 | * 442 | * @param {object} data 443 | * @param {object} [links] 444 | * @param {Resource} rootResource 445 | * @param {Resource} resource 446 | */ 447 | function extractAndUpdateResources(data, links, rootResource, resource) { 448 | var resources = []; 449 | 450 | var selfHref = ((data._links || {}).self || {}).href; 451 | if (!selfHref) { 452 | throw new Error('Self link href expected but not found'); 453 | } 454 | 455 | // Extract links 456 | angular.extend(links, data._links); 457 | delete data._links; 458 | 459 | // Extract and update embedded resources 460 | Object.keys(data._embedded || {}).forEach(function (rel) { 461 | var embeds = data._embedded[rel]; 462 | 463 | // Add link to embedded resource if missing 464 | if (!(rel in links)) { 465 | links[rel] = forArray(embeds, function (embedded) { 466 | return {href: embedded._links.self.href}; 467 | }); 468 | } 469 | // Recurse into embedded resource 470 | forArray(embeds, function (embedded) { 471 | resources = resources.concat(extractAndUpdateResources(embedded, {}, rootResource, null)); 472 | }); 473 | }); 474 | delete data._embedded; 475 | 476 | // Update resource 477 | if (!resource) resource = rootResource.$context.get(links.self.href, rootResource.constructor); 478 | Resource.prototype.$update.call(resource, data, links); 479 | resources.push(resource); 480 | 481 | return resources; 482 | } 483 | }]) 484 | 485 | ; 486 | 487 | 'use strict'; 488 | 489 | angular.module('hypermedia') 490 | 491 | /** 492 | * @ngdoc type 493 | * @name Resource 494 | * @description 495 | * 496 | * Hypermedia resource. 497 | */ 498 | .factory('Resource', ['$log', '$q', 'HypermediaUtil', function ($log, $q, HypermediaUtil) { 499 | var forArray = HypermediaUtil.forArray; 500 | 501 | var registeredProfiles = {}; 502 | 503 | /** 504 | * Resource. 505 | * 506 | * @constructor 507 | * @param {string} uri the resource URI 508 | * @param {ResourceContext} context the resource context 509 | */ 510 | function Resource(uri, context) { 511 | // This constructor does not use the automatically created object but instantiate from a subclass instead 512 | 513 | // Intermediate prototype to add profile-specific properties to 514 | var prototype = Object.create(Object.getPrototypeOf(this)); 515 | 516 | // Current profile(s) 517 | var profile = null; 518 | 519 | // Instantiated object 520 | return Object.create(prototype, { 521 | 522 | /** 523 | * The resource URI. 524 | * 525 | * @property {string} 526 | */ 527 | $uri: {value: uri}, 528 | 529 | /** 530 | * The resource context. Can be used to get related resources. 531 | * 532 | * @property {ResourceContext} 533 | */ 534 | $context: {value: context}, 535 | 536 | /** 537 | * Links to other resources. 538 | * 539 | * @property {object} 540 | */ 541 | $links: {value: { 542 | self: { 543 | href: uri 544 | } 545 | }, writable: true}, 546 | 547 | /** 548 | * The timestamp of the last successful GET or PUT request. 549 | * 550 | * @property {number} Resource.syncTime 551 | * @see ResourceContext#markSynced 552 | */ 553 | $syncTime: {value: null, writable: true}, 554 | 555 | /** 556 | * The resource profile URI(s). If profile properties have been registered for this URI (using 557 | * HalContextProvider.registerProfile or ResourceContext.registerProfile), the properties will be defined on the 558 | * resource. 559 | * 560 | * Setting the profile to 'undefined' or 'null' will remove the profile. 561 | * 562 | * @property {string|string[]} 563 | */ 564 | $profile: { 565 | get: function () { 566 | return profile; 567 | }, 568 | set: function (value) { 569 | // Remove old profile properties 570 | if (profile) { 571 | var oldProfiles = angular.isArray(profile) ? profile : [profile]; 572 | oldProfiles.forEach(function (profile) { 573 | var properties = registeredProfiles[profile] || {}; 574 | Object.keys(properties).forEach(function (key) { 575 | delete prototype[key]; 576 | }); 577 | }); 578 | } 579 | 580 | // Apply new profile properties 581 | if (value) { 582 | var newProfiles = angular.isArray(value) ? value : [value]; 583 | newProfiles.forEach(function (profile) { 584 | var properties = registeredProfiles[profile] || {}; 585 | Object.defineProperties(prototype, properties); 586 | }); 587 | } 588 | 589 | profile = value; 590 | } 591 | } 592 | }); 593 | } 594 | 595 | // Prototype properties 596 | Resource.prototype = Object.create(Object.prototype, { 597 | constructor: {value: Resource}, 598 | 599 | /** 600 | * Whether the resource was synchronized with the server. 601 | * 602 | * @property {boolean} 603 | */ 604 | $isSynced: {get: function () { 605 | return !!this.$syncTime; 606 | }} 607 | }); 608 | 609 | HypermediaUtil.defineProperties(Resource.prototype, { 610 | /** 611 | * Resolve the href of a property. 612 | * 613 | * @function 614 | * @param {string} prop the property name 615 | * @param {object} [vars] URI template variables 616 | * @returns {string|string[]} the link href or hrefs 617 | */ 618 | $propHref: {value: function (prop, vars) { 619 | return forArray(this[prop], function (uri) { 620 | if (vars) uri = new UriTemplate(uri).fillFromObject(vars); 621 | return uri; 622 | }); 623 | }}, 624 | 625 | /** 626 | * Follow a property relation to another resource. 627 | * 628 | * @function 629 | * @param {string} prop the property name 630 | * @param {object} [vars] URI template variables 631 | * @param {ResourceFactory} [factory] the factory for creating the resource 632 | * @returns {Resource|Resource[]} the linked resource or resources 633 | */ 634 | $propRel: {value: function (prop, vars, factory) { 635 | if (angular.isFunction(vars)) { 636 | factory = vars; 637 | vars = undefined; 638 | } 639 | return forArray(this.$propHref(prop, vars), function (uri) { 640 | return this.$context.get(uri, factory); 641 | }, this); 642 | }}, 643 | 644 | /** 645 | * Resolve the href of a link relation. 646 | * 647 | * @function 648 | * @param {string} rel the link relation 649 | * @param {object} [vars] URI template variables 650 | * @returns {string|string[]} the link href or hrefs 651 | */ 652 | $linkHref: {value: function (rel, vars) { 653 | var templated = false; 654 | var nonTemplated = false; 655 | var deprecation = {}; 656 | 657 | var linkHrefs = forArray(this.$links[rel], function (link) { 658 | if ('templated' in link) templated = true; 659 | if (!('templated' in link)) nonTemplated = true; 660 | if ('deprecation' in link) deprecation[link.deprecation] = true; 661 | 662 | var uri = link.href; 663 | if (vars) uri = new UriTemplate(uri).fillFromObject(vars); 664 | return uri; 665 | }, this); 666 | 667 | if (templated && !vars) { 668 | $log.warn("Following templated link relation '" + rel + "' without variables"); 669 | } 670 | if (nonTemplated && vars) { 671 | $log.warn("Following non-templated link relation '" + rel + "' with variables"); 672 | } 673 | var deprecationUris = Object.keys(deprecation); 674 | if (deprecationUris.length > 0) { 675 | $log.warn("Following deprecated link relation '" + rel + "': " + deprecationUris.join(', ')); 676 | } 677 | 678 | return linkHrefs; 679 | }}, 680 | 681 | /** 682 | * Follow a link relation to another resource. 683 | * 684 | * @function 685 | * @param {string} rel the link relation 686 | * @param {object} [vars] URI template variables 687 | * @param {ResourceFactory} [factory] the factory for creating the related resource 688 | * @returns {Resource|Resource[]} the linked resource or resources 689 | */ 690 | $linkRel: {value: function (rel, vars, factory) { 691 | if (angular.isFunction(vars)) { 692 | factory = vars; 693 | vars = undefined; 694 | } 695 | return forArray(this.$linkHref(rel, vars), function (uri) { 696 | return this.$context.get(uri, factory); 697 | }, this); 698 | }}, 699 | 700 | /** 701 | * Perform an HTTP GET request if the resource is not synchronized or if 702 | * the resource was synced before timestamp passed as argument. 703 | * 704 | * @function 705 | * @param {number} [ts] timestamp to check against 706 | * @returns a promise that is resolved to the resource 707 | * @see Resource#$syncTime 708 | */ 709 | $load: {value: function (ts) { 710 | if (!this.$syncTime || (ts && this.$syncTime < ts)) { 711 | return this.$context.httpGet(this); 712 | } else { 713 | return $q.when(this); 714 | } 715 | }}, 716 | 717 | /** 718 | * Perform an HTTP GET request if the resource was synced before 719 | * the timestamp passed as argument. 720 | * 721 | * @function 722 | * @param {number} [ts] timestamp to check against; default: Date.now() 723 | * @returns a promise that is resolved to the resource 724 | * @see Resource#$syncTime 725 | */ 726 | $refresh: {value: function (ts) { 727 | if (!ts) ts = Date.now(); 728 | return this.$load(ts); 729 | }}, 730 | 731 | /** 732 | * Load all resources reachable from a resource using one or more paths. 733 | * A path is on object hierarchy containing property or relation names. 734 | * If the name matches a property it is loaded, otherwise it is 735 | * interpreted as a link relation. 736 | * 737 | * Examples: 738 | * context.loadPaths(resource, {team_url: {}}) 739 | * context.loadPaths(resource, {'http://example.com/owner': {}}) 740 | * context.loadPaths(resource, { 741 | * home: { 742 | * address: {} 743 | * } 744 | * }) 745 | * context.loadPaths(resource, { 746 | * 'ex:car': {}, 747 | * 'ex:friends': { 748 | * 'ex:car': {} 749 | * } 750 | * }) 751 | * 752 | * @function 753 | * @param {Resource} resource 754 | * @param {object} paths 755 | * @param {number} [ts] timestamp to check against 756 | * @return {Promise} a promise that resolves to the resource once all 757 | * paths have been loaded 758 | * @see {@link #$load} 759 | */ 760 | $loadPaths: {value: function (paths, ts, path_prefix, root_uri) { 761 | var self = this; 762 | if (!path_prefix) { 763 | path_prefix = []; 764 | root_uri = self.$uri; 765 | } 766 | return self.$load(ts).then(function () { 767 | var promises = []; 768 | Object.keys(paths).forEach(function (key) { 769 | var full_path = path_prefix.concat(key); 770 | var uris = self.$propHref(key); 771 | if (!uris) uris = self.$linkHref(key); 772 | if (!uris) { 773 | $log.warn('Warning while loading path "' + full_path.join('.') + '" from resource "' + root_uri + '": ' + 774 | 'property or link "' + key + '" not found on resource "' + self.$uri + '"'); 775 | return; 776 | } 777 | 778 | uris = angular.isArray(uris) ? uris : [uris]; 779 | uris.forEach(function (uri) { 780 | var related = (typeof uri === 'string') ? self.$context.get(uri) : uri; 781 | promises.push(related.$loadPaths(paths[key], ts, full_path, root_uri)); 782 | }); 783 | }); 784 | return $q.all(promises); 785 | }).then(function () { 786 | return self; 787 | }); 788 | }}, 789 | 790 | /** 791 | * Refresh all resources reachable from a resource using one or more paths. 792 | * 793 | * @function 794 | * @param {Resource} resource 795 | * @param {object} paths 796 | * @param {number} [ts] timestamp to check against; default: Date.now() 797 | * @return {Promise} a promise that resolves to the resource once all 798 | * paths have been loaded 799 | * @see {@link #$loadPaths} 800 | */ 801 | $refreshPaths: {value: function (paths, ts) { 802 | if (!ts) ts = Date.now(); 803 | return this.$loadPaths(paths, ts); 804 | }}, 805 | 806 | /** 807 | * Create a $http GET request configuration object. 808 | * 809 | * @function 810 | * @returns {object} 811 | */ 812 | $getRequest: {value: function () { 813 | return { 814 | method: 'get', 815 | url: this.$uri, 816 | headers: {'Accept': 'application/json'} 817 | }; 818 | }}, 819 | 820 | /** 821 | * Perform an HTTP GET request. 822 | * 823 | * @function 824 | * @returns a promise that is resolved to the resource 825 | */ 826 | $get: {value: function () { 827 | return this.$context.httpGet(this); 828 | }}, 829 | 830 | /** 831 | * Create a $http PUT request configuration object. 832 | * 833 | * @function 834 | * @returns {object} 835 | */ 836 | $putRequest: {value: function () { 837 | return { 838 | method: 'put', 839 | url: this.$uri, 840 | data: this, 841 | headers: {'Content-Type': 'application/json'} 842 | }; 843 | }}, 844 | 845 | /** 846 | * Perform an HTTP PUT request with the resource state. 847 | * 848 | * @function 849 | * @returns a promise that is resolved to the resource 850 | */ 851 | $put: {value: function () { 852 | return this.$context.httpPut(this); 853 | }}, 854 | 855 | /** 856 | * Create a $http PATCH request configuration object. 857 | * 858 | * @function 859 | * @returns {object} 860 | */ 861 | $patchRequest: {value: function (data) { 862 | return { 863 | method: 'patch', 864 | url: this.$uri, 865 | data: data, 866 | headers: {'Content-Type': 'application/merge-patch+json'} 867 | }; 868 | }}, 869 | 870 | /** 871 | * Perform an HTTP PATCH request with the resource state. 872 | * 873 | * @function 874 | * @returns a promise that is resolved to the resource 875 | */ 876 | $patch: {value: function (data) { 877 | return this.$context.httpPatch(this, data); 878 | }}, 879 | 880 | /** 881 | * Create a $http DELETE request configuration object. 882 | * 883 | * @function 884 | * @returns {object} 885 | */ 886 | $deleteRequest: {value: function () { 887 | return { 888 | method: 'delete', 889 | url: this.$uri 890 | }; 891 | }}, 892 | 893 | /** 894 | * Perform an HTTP DELETE request. 895 | * 896 | * @function 897 | * @returns a promise that is resolved to the resource 898 | */ 899 | $delete: {value: function () { 900 | return this.$context.httpDelete(this); 901 | }}, 902 | 903 | /** 904 | * Create a $http POST request configuration object. 905 | * 906 | * @function 907 | * @param {*} data request body 908 | * @param {object} [headers] request headers 909 | * @param {ConfigHttp} [callback] a function that changes the $http request config 910 | * @returns {object} 911 | */ 912 | $postRequest: {value: function (data, headers, callback) { 913 | callback = callback || angular.identity; 914 | return callback({ 915 | method: 'post', 916 | url: this.$uri, 917 | data: data, 918 | headers: headers || {} 919 | }); 920 | }}, 921 | 922 | /** 923 | * Perform an HTTP POST request. 924 | * 925 | * @function 926 | * @param {*} data request body 927 | * @param {object} [headers] request headers 928 | * @param {ConfigHttp} [callback] a function that changes the $http request config 929 | * @returns a promise that is resolved to the response 930 | */ 931 | $post: {value: function (data, headers, callback) { 932 | return this.$context.httpPost(this, data, headers, callback); 933 | }}, 934 | 935 | /** 936 | * Update the resource with new data by clearing all existing properties 937 | * and then copying all properties from 'data'. 938 | * 939 | * @function 940 | * @param {object} data 941 | * @param {object} [links] 942 | * @returns the resource 943 | */ 944 | $update: {value: function (data, links) { 945 | links = links || {}; 946 | var selfHref = ((links || {}).self || {}).href; 947 | if (selfHref && selfHref !== this.$uri) { 948 | if (this.$context.enableAliases) { 949 | this.$context.addAlias(selfHref, this.$uri); 950 | } else { 951 | throw new Error('Self link href differs: expected "' + this.$uri + '", was ' + 952 | angular.toJson(selfHref)); 953 | } 954 | } 955 | 956 | // Update resource 957 | Object.keys(this).forEach(function (key) { 958 | if (key.indexOf('$$') !== 0) { 959 | delete this[key]; 960 | } 961 | }, this); 962 | Object.keys(data).forEach(function (key) { 963 | if (key.indexOf('$$') !== 0) { 964 | this[key] = data[key]; 965 | } 966 | }, this); 967 | 968 | this.$links = {self: {href: this.$uri}}; // Add default self link 969 | angular.extend(this.$links, links); 970 | 971 | // Optionally apply profile(s) 972 | var profileUris = forArray(links.profile, function (link) { 973 | return link.href; 974 | }); 975 | if (profileUris) this.$profile = profileUris; 976 | 977 | return this; 978 | }}, 979 | 980 | /** 981 | * Merges the resource with new data following algorithm defined 982 | * in JSON Merge Patch specification (RFC 7386, https://tools.ietf.org/html/rfc7386). 983 | * 984 | * @function 985 | * @param {object} data 986 | * @param {object} [links] 987 | * @returns the resource 988 | */ 989 | $merge: {value: function (data) { 990 | var mergePatch = function (target, patch) { 991 | if (!angular.isObject(patch) || patch === null || Array.isArray(patch)) { 992 | return patch; 993 | } 994 | 995 | if (!angular.isObject(target) || target === null || Array.isArray(target)) { 996 | target = {}; 997 | } 998 | 999 | Object.keys(patch).forEach(function (key) { 1000 | var value = patch[key]; 1001 | if (value === null) { 1002 | delete target[key]; 1003 | } else { 1004 | target[key] = mergePatch(target[key], value); 1005 | } 1006 | }); 1007 | 1008 | return target; 1009 | }; 1010 | 1011 | return mergePatch(this, data); 1012 | }} 1013 | }); 1014 | 1015 | // Class properties 1016 | HypermediaUtil.defineProperties(Resource, { 1017 | 1018 | /** 1019 | * Register a profile. 1020 | * 1021 | * @function 1022 | * @param {string} profile the profile URI 1023 | * @param {object} properties a properties object as used in 'Object.defineProperties()' 1024 | */ 1025 | registerProfile: {value: function (profile, properties) { 1026 | // Make sure properties can be removed when applying a different profile 1027 | var props = angular.copy(properties); 1028 | angular.forEach(props, function (prop) { 1029 | prop.configurable = true; 1030 | }); 1031 | registeredProfiles[profile] = props; 1032 | }}, 1033 | 1034 | /** 1035 | * Register profiles. 1036 | * 1037 | * @function 1038 | * @param {object} profiles an object mapping profile URIs to properties objects as used in 1039 | * 'Object.defineProperties()' 1040 | */ 1041 | registerProfiles: {value: function (profiles) { 1042 | angular.forEach(profiles, function (properties, profile) { 1043 | Resource.registerProfile(profile, properties); 1044 | }); 1045 | }} 1046 | }); 1047 | 1048 | return Resource; 1049 | }]) 1050 | 1051 | ; 1052 | 1053 | /** 1054 | * A callback function used to change a $http config object. 1055 | * 1056 | * @callback ConfigHttp 1057 | * @param {object} config the $http config object 1058 | * @returns {object} the $http config object 1059 | */ 1060 | 1061 | 'use strict'; 1062 | 1063 | angular.module('hypermedia') 1064 | 1065 | /** 1066 | * @ngdoc object 1067 | * @name HypermediaUtil 1068 | * @description 1069 | * 1070 | * Utility functions used in the hypermedia module. 1071 | */ 1072 | .factory('HypermediaUtil', function () { 1073 | return { 1074 | 1075 | /** 1076 | * Call a function on an argument or every element of an array. 1077 | * 1078 | * @param {Array|*|undefined} arg the variable or array of variables to apply 'func' to 1079 | * @param {function} func the function 1080 | * @param {object} [context] object to bind 'this' to when applying 'func' 1081 | * @returns {Array|*|undefined} the result of applying 'func' to 'arg'; undefined if 'arg' is undefined 1082 | */ 1083 | forArray: function forArray(arg, func, context) { 1084 | if (angular.isUndefined(arg)) return undefined; 1085 | if (Array.isArray(arg)) { 1086 | return arg.map(function (elem) { 1087 | return func.call(context, elem); 1088 | }); 1089 | } else { 1090 | return func.call(context, arg); 1091 | } 1092 | }, 1093 | 1094 | /** 1095 | * Call Object.defineProperties but configure all properties as writable. 1096 | */ 1097 | defineProperties: function defineProperties(obj, props) { 1098 | props = angular.copy(props); 1099 | angular.forEach(props, function (prop) { 1100 | if (!('writable' in prop)) prop.writable = true; 1101 | }); 1102 | Object.defineProperties(obj, props); 1103 | } 1104 | }; 1105 | }) 1106 | 1107 | ; 1108 | 1109 | 'use strict'; 1110 | 1111 | angular.module('hypermedia') 1112 | 1113 | .run(['$q', 'ResourceContext', 'VndError',function ($q, ResourceContext, VndError) { 1114 | var vndErrorHandler = function (response) { 1115 | return new VndError(response.data); 1116 | }; 1117 | 1118 | ResourceContext.registerErrorHandler('application/vnd.error+json', vndErrorHandler); 1119 | }]) 1120 | 1121 | /** 1122 | * @ngdoc type 1123 | * @name VndError 1124 | * @description 1125 | * 1126 | * VndError represents errors from server with content type 'application/vnd+error', 1127 | * see: https://github.com/blongden/vnd.error 1128 | */ 1129 | .factory('VndError', function () { 1130 | var VndError = function (data) { 1131 | this.message = data.message; 1132 | this.logref = data.logref; 1133 | this.path = data.path; 1134 | this.$links = data._links || []; 1135 | 1136 | this.$nested = []; 1137 | var embeds = data._embedded && data._embedded.errors; 1138 | if (embeds) { 1139 | if (!Array.isArray(embeds)) { 1140 | embeds = [embeds]; 1141 | } 1142 | embeds.forEach(function (embed) { 1143 | this.$nested.push(new VndError(embed)); 1144 | }, this); 1145 | } 1146 | }; 1147 | 1148 | return VndError; 1149 | }) 1150 | 1151 | 1152 | ; 1153 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'), 4 | gulp = require('gulp'), 5 | batch = require('gulp-batch'), 6 | concat = require('gulp-concat'), 7 | ignore = require('gulp-ignore'), 8 | jshint = require('gulp-jshint'), 9 | jscs = require('gulp-jscs'), 10 | replace = require('gulp-replace'), 11 | watch = require('gulp-watch'), 12 | path = require('path'); 13 | 14 | var dist = 'dist/hypermedia.js'; 15 | 16 | gulp.task('default', function () { 17 | var distDir = path.dirname(dist); 18 | var pkg = JSON.parse(fs.readFileSync('package.json')); 19 | 20 | return gulp.src('src/*.js') 21 | // jshint 22 | .pipe(jshint()) 23 | .pipe(jshint.reporter('jshint-stylish')) 24 | .pipe(jshint.reporter('fail')) 25 | // jscs 26 | .pipe(jscs()) 27 | .pipe(jscs.reporter()) 28 | .pipe(jscs.reporter('fail')) 29 | // build 30 | .pipe(ignore.exclude('**/*.spec.js')) 31 | .pipe(concat(path.basename(dist))) 32 | .pipe(replace(/@version \S+/, '@version ' + pkg.version)) 33 | .pipe(gulp.dest(distDir)); 34 | }); 35 | 36 | gulp.task('watch', function () { 37 | gulp.start('default'); 38 | watch('src/**', batch(function (events, done) { 39 | gulp.start('default', done); 40 | })); 41 | }); 42 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('./dist/hypermedia.js'); 2 | 3 | /* commonjs package manager support (eg componentjs) */ 4 | if (typeof module !== "undefined" && typeof exports !== "undefined" && module.exports === exports) { 5 | module.exports = 'hypermedia'; 6 | } 7 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | 6 | files: [ 7 | 'node_modules/angular/angular.js', 8 | 'node_modules/angular-mocks/angular-mocks.js', 9 | 'node_modules/linkheader-parser/dist/linkheader-parser-browser.js', 10 | 'node_modules/mediatype-parser/dist/mediatype-parser-browser.js', 11 | 'node_modules/uri-templates/uri-templates.js', 12 | 'src/*.js' 13 | ], 14 | 15 | autoWatch: true, 16 | 17 | frameworks: ['jasmine'], 18 | 19 | browsers: ['PhantomJS2'], 20 | 21 | preprocessors: { 22 | 'src/**/!(*.spec)+(.js)': ['coverage'] 23 | }, 24 | 25 | reporters: ['progress', 'coverage'], 26 | 27 | coverageReporter: { 28 | dir: 'build/coverage', 29 | reporters: [ 30 | {type: 'text-summary'}, 31 | {type: 'html', subdir: '.'}, 32 | {type: 'lcovonly', subdir: '.'} 33 | ] 34 | } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-hypermedia", 3 | "version": "1.0.1", 4 | "description": "Hypermedia REST API client for AngularJS applications", 5 | "repository": "https://github.com/jcassee/angular-hypermedia", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "dependencies": { 9 | "angular": "^1.3", 10 | "linkheader-parser": "^0.1", 11 | "mediatype-parser": "^0.1", 12 | "uri-templates": "^0.1" 13 | }, 14 | "devDependencies": { 15 | "angular-mocks": "^1.3", 16 | "coveralls": "^2.11", 17 | "gulp": "^3.9", 18 | "gulp-batch": "^1.0", 19 | "gulp-concat": "^2.6", 20 | "gulp-ignore": "^2.0", 21 | "gulp-jscs": "^3.0", 22 | "gulp-jshint": "^2.0", 23 | "gulp-replace": "^0.5", 24 | "gulp-watch": "^4.3", 25 | "jasmine-core": "^2.3", 26 | "jshint": "^2.8", 27 | "jshint-stylish": "^2.0", 28 | "karma": "^0.13", 29 | "karma-coverage": "^0.4", 30 | "karma-jasmine": "^0.3", 31 | "karma-phantomjs2-launcher": "^0.5", 32 | "phantomjs-prebuilt": "^2.1" 33 | }, 34 | "scripts": { 35 | "pretest": "gulp", 36 | "test": "karma start karma.conf.js --single-run", 37 | "posttest": "[ x$TRAVIS = x ] || ( cat build/coverage/lcov.info | coveralls )" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/_module.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc module 5 | * @name halresource 6 | * @version development 7 | * @description 8 | * 9 | * This module contains classes and services to work with hypermedia APIs. 10 | */ 11 | angular.module('hypermedia', []); 12 | -------------------------------------------------------------------------------- /src/blobresource.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('hypermedia') 4 | 5 | /** 6 | * @ngdoc type 7 | * @name BlobResource 8 | * @description 9 | * 10 | * Resource containing binary data. 11 | */ 12 | .factory('BlobResource', ['Resource', 'HypermediaUtil', function (Resource, HypermediaUtil) { 13 | 14 | /** 15 | * Resource with a media type and some data. 16 | * 17 | * @constructor 18 | * @param {string} uri the resource URI 19 | * @param {ResourceContext} context the context object 20 | */ 21 | function BlobResource(uri, context) { 22 | var instance = Resource.call(this, uri, context); 23 | 24 | /** 25 | * The resource data. 26 | * 27 | * @type {Blob} 28 | */ 29 | instance.data = ''; 30 | 31 | return instance; 32 | } 33 | 34 | // Prototype properties 35 | BlobResource.prototype = Object.create(Resource.prototype, { 36 | constructor: {value: BlobResource} 37 | }); 38 | 39 | HypermediaUtil.defineProperties(BlobResource.prototype, { 40 | /** 41 | * Create a $http GET request configuration object. 42 | * 43 | * @function 44 | * @returns {object} 45 | */ 46 | $getRequest: {value: function () { 47 | return { 48 | method: 'get', 49 | url: this.$uri, 50 | headers: {'Accept': '*/*'}, 51 | responseType: 'blob', 52 | addTransformResponse: function (data) { 53 | return {data: data}; 54 | } 55 | }; 56 | }}, 57 | 58 | /** 59 | * Create a $http PUT request configuration object. 60 | * 61 | * @function 62 | * @returns {object} 63 | */ 64 | $putRequest: {value: function () { 65 | return { 66 | method: 'put', 67 | url: this.$uri, 68 | data: this.data, 69 | headers: {'Content-Type': this.data.type || 'binary/octet-stream'} 70 | }; 71 | }}, 72 | 73 | /** 74 | * Throw an error. Binary resources have no obvious PATCH semantics. 75 | */ 76 | $patchRequest: {value: function () { 77 | throw new Error('BlobResource does not support the PATCH method'); 78 | }} 79 | }); 80 | 81 | return BlobResource; 82 | }]) 83 | 84 | ; 85 | -------------------------------------------------------------------------------- /src/blobresource.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('BlobResource', function () { 4 | beforeEach(module('hypermedia')); 5 | 6 | // Setup 7 | 8 | var $log, $q, $rootScope, BlobResource, mockContext, uri, resource; 9 | 10 | beforeEach(inject(function (_$log_, _$q_, _$rootScope_, _BlobResource_) { 11 | $log = _$log_; 12 | $q = _$q_; 13 | $rootScope = _$rootScope_; 14 | BlobResource = _BlobResource_; 15 | mockContext = jasmine.createSpyObj('mockContext', ['get', 'httpGet', 'httpPut', 'httpDelete', 'httpPost']); 16 | uri = 'http://example.com'; 17 | resource = new BlobResource(uri, mockContext); 18 | })); 19 | 20 | // Tests 21 | 22 | it('is initialized correctly', function () { 23 | expect(resource.$uri).toBe(uri); 24 | expect(resource.$context).toBe(mockContext); 25 | expect(resource.$links).toEqual({self: {href: uri}}); 26 | expect(resource.$syncTime).toBeNull(); 27 | expect(resource.$profile).toBeNull(); 28 | }); 29 | 30 | // HTTP 31 | 32 | it('creates HTTP GET request', function () { 33 | expect(resource.$getRequest()).toEqual({ 34 | method: 'get', 35 | url: 'http://example.com', 36 | headers: {Accept: '*/*'}, 37 | responseType: 'blob', 38 | addTransformResponse: jasmine.any(Function) 39 | }); 40 | }); 41 | 42 | it('creates HTTP PUT request using the data media type', function () { 43 | resource.data = new Blob(['test'], {type: 'text/plain'}); 44 | expect(resource.$putRequest()).toEqual({ 45 | method: 'put', 46 | url: 'http://example.com', 47 | data: resource.data, 48 | headers: {'Content-Type': 'text/plain'} 49 | }); 50 | }); 51 | 52 | it('creates HTTP PUT request using binary/octet-stream media type if data has no type', function () { 53 | resource.data = new Blob(['test']); 54 | expect(resource.$putRequest()).toEqual({ 55 | method: 'put', 56 | url: 'http://example.com', 57 | data: resource.data, 58 | headers: {'Content-Type': 'binary/octet-stream'} 59 | }); 60 | }); 61 | 62 | it('transforms HTTP GET response data into "data" property', function () { 63 | var request = resource.$getRequest(); 64 | var data = new Blob(['test']); 65 | expect(request.addTransformResponse(data)).toEqual({data: data}); 66 | }); 67 | 68 | it('does not support HTTP PATCH request', function () { 69 | expect(resource.$patchRequest).toThrowError('BlobResource does not support the PATCH method'); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('hypermedia') 4 | 5 | /** 6 | * @ngdoc type 7 | * @name ResourceContext 8 | * @description 9 | * 10 | * Context for working with hypermedia resources. The context has methods 11 | * for making HTTP requests and acts as an identity map. 12 | */ 13 | .factory('ResourceContext', ['$http', '$log', '$q', 'Resource', 'HypermediaUtil', function ($http, $log, $q, Resource, HypermediaUtil) { 14 | 15 | var busyRequests = 0; 16 | var errorHandlers = {}; 17 | 18 | /** 19 | * Resource context. 20 | * 21 | * @constructor 22 | * @param {ResourceFactory} [resourceFactory] 23 | */ 24 | function ResourceContext(resourceFactory) { 25 | this.resourceFactory = resourceFactory || ResourceContext.defaultResourceFactory; 26 | this.resources = {}; 27 | } 28 | 29 | Object.defineProperties(ResourceContext, { 30 | 31 | /** 32 | * The default resource factory. 33 | * 34 | * @property {resourceFactory} 35 | */ 36 | defaultResourceFactory: {value: Resource, writable: true}, 37 | 38 | /** 39 | * The number of current HTTP requests. 40 | * 41 | * @property {number} 42 | */ 43 | busyRequests: {get: function () { 44 | return busyRequests; 45 | }}, 46 | 47 | registerErrorHandler: {value: function (contentType, handler) { 48 | errorHandlers[contentType] = handler; 49 | }}, 50 | 51 | /** 52 | * Whether resource aliases are allowed by default. 53 | */ 54 | defaultEnableAliases: {value: true, writable: true} 55 | }); 56 | 57 | ResourceContext.prototype = Object.create(Object.prototype, { 58 | constructor: {value: ResourceContext} 59 | }); 60 | 61 | HypermediaUtil.defineProperties(ResourceContext.prototype, { 62 | /** 63 | * Get the resource for an URI. Creates a new resource if not already in the context. 64 | * 65 | * @function 66 | * @param {string} uri 67 | * @param {ResourceFactory} [Factory] optional resource creation function 68 | * @returns {Resource} 69 | */ 70 | get: {value: function (uri, Factory) { 71 | var resource = this.resources[uri]; 72 | if (!resource) { 73 | Factory = (Factory || this.resourceFactory); 74 | if (!Factory) throw new Error('No resource factory: ' + uri); 75 | resource = this.resources[uri] = new Factory(uri, this); 76 | } 77 | return resource; 78 | }}, 79 | 80 | /** 81 | * Copy a resource into this context. 82 | * 83 | * @function 84 | * @param {Resource} resource 85 | * @returns {Resource} a copy of the resource in this context 86 | */ 87 | copy: {value: function (resource) { 88 | var copy = this.get(resource.$uri); 89 | copy.$update(resource, resource.$links); 90 | return copy; 91 | }}, 92 | 93 | /** 94 | * Whether resaurce aliases are enabled. If false, context.addAlias throws an error. 95 | */ 96 | enableAliases: {value: ResourceContext.defaultEnableAliases, writable: true}, 97 | 98 | /** 99 | * Adds an alias to an existing resource. 100 | * 101 | * @function 102 | * @param {string} aliasUri the new URI to point to the original resource 103 | * @param {string} originalUri the URI of the original resource. 104 | */ 105 | addAlias: {value: function (aliasUri, originalUri) { 106 | if (!this.enableAliases) throw new Error('Resource aliases not enabled'); 107 | this.resources[aliasUri] = this.resources[originalUri]; 108 | }}, 109 | 110 | /** 111 | * Perform a HTTP GET request on a resource. 112 | * 113 | * @function 114 | * @param {Resource} resource 115 | * @returns a promise that is resolved to the resource 116 | * @see Resource#$getRequest 117 | */ 118 | httpGet: {value: function (resource) { 119 | var self = this; 120 | busyRequests += 1; 121 | var request = updateHttp(resource.$getRequest()); 122 | return $http(request).then(function (response) { 123 | var links = parseLinkHeader(response.headers('Link')); 124 | 125 | // Convert media type profile to profile link 126 | var mediaType = mediaTypeParser.parse(response.headers('Content-Type')); 127 | if (!('profile' in links) && 'profile' in mediaType.params) { 128 | links.profile = {href: mediaType.params.profile}; 129 | } 130 | 131 | var updatedResources = resource.$update(response.data, links); 132 | return self.markSynced(updatedResources, Date.now()); 133 | }, handleErrorResponse).then(function () { 134 | return resource; 135 | }).finally(function () { 136 | busyRequests -= 1; 137 | }); 138 | }}, 139 | 140 | /** 141 | * Perform a HTTP PUT request. 142 | * 143 | * @function 144 | * @param {Resource} resource 145 | * @returns a promise that is resolved to the resource 146 | * @see Resource#$putRequest 147 | */ 148 | httpPut: {value: function (resource) { 149 | var self = this; 150 | busyRequests += 1; 151 | var request = updateHttp(resource.$putRequest()); 152 | return $http(request).then(function () { 153 | return self.markSynced(resource, Date.now()); 154 | }, handleErrorResponse).then(function () { 155 | return resource; 156 | }).finally(function () { 157 | busyRequests -= 1; 158 | }); 159 | }}, 160 | 161 | /** 162 | * Perform a HTTP PATCH request. 163 | * 164 | * @function 165 | * @param {Resource} resource 166 | * @returns a promise that is resolved to the resource 167 | * @see Resource#$patchRequest 168 | */ 169 | httpPatch: {value: function (resource, data) { 170 | var self = this; 171 | busyRequests += 1; 172 | var request = updateHttp(resource.$patchRequest(data)); 173 | return $http(request).then(function () { 174 | resource.$merge(request.data); 175 | return self.markSynced(resource, Date.now()); 176 | }, handleErrorResponse).then(function () { 177 | return resource; 178 | }).finally(function () { 179 | busyRequests -= 1; 180 | }); 181 | }}, 182 | 183 | /** 184 | * Perform a HTTP DELETE request and unmark the resource as synchronized. 185 | * 186 | * @function 187 | * @param {Resource} resource 188 | * @returns a promise that is resolved to the resource 189 | * @see Resource#$deleteRequest 190 | */ 191 | httpDelete: {value: function (resource) { 192 | var self = this; 193 | busyRequests += 1; 194 | var request = updateHttp(resource.$deleteRequest()); 195 | return $http(request).then(function () { 196 | delete self.resources[resource.$uri]; 197 | return self.markSynced(resource, null); 198 | }, handleErrorResponse).then(function () { 199 | return resource; 200 | }).finally(function () { 201 | busyRequests -= 1; 202 | }); 203 | }}, 204 | 205 | /** 206 | * Perform a HTTP POST request. 207 | * 208 | * @function 209 | * @param {Resource} resource 210 | * @param {*} data request body 211 | * @param {object} [headers] request headers 212 | * @param {ConfigHttp} [callback] a function that changes the $http request config 213 | * @returns a promise that is resolved to the response 214 | * @see Resource#$postRequest 215 | */ 216 | httpPost: {value: function (resource, data, headers, callback) { 217 | busyRequests += 1; 218 | var request = updateHttp(resource.$postRequest(data, headers, callback)); 219 | return $http(request).catch(handleErrorResponse).finally(function () { 220 | busyRequests -= 1; 221 | }); 222 | }}, 223 | 224 | /** 225 | * Mark a resource as synchronized with the server. 226 | * 227 | * @function 228 | * @param {Resource|Resource[]} resources 229 | * @param {number} syncTime the timestamp of the last synchronization 230 | * @returns a promise that is resolved when the resources have been marked 231 | * @see Resource#syncTime 232 | */ 233 | markSynced: {value: function (resources, syncTime) { 234 | resources = angular.isArray(resources) ? resources : [resources]; 235 | resources.forEach(function (resource) { 236 | resource.$syncTime = syncTime; 237 | }); 238 | return $q.when(); 239 | }} 240 | }); 241 | 242 | return ResourceContext; 243 | 244 | 245 | function appendTransform(defaults, transform) { 246 | if (!transform) return defaults; 247 | defaults = angular.isArray(defaults) ? defaults : [defaults]; 248 | return defaults.concat(transform); 249 | } 250 | 251 | function updateHttp(config) { 252 | config.transformRequest = appendTransform($http.defaults.transformRequest, config.addTransformRequest); 253 | config.transformResponse = appendTransform($http.defaults.transformResponse, config.addTransformResponse); 254 | return config; 255 | } 256 | 257 | function parseLinkHeader(header) { 258 | return header ? linkHeaderParser.parse(header) : {}; 259 | } 260 | 261 | function handleErrorResponse(response) { 262 | var contentType = response.headers('Content-Type'); 263 | var handler = errorHandlers[contentType]; 264 | response.error = (handler ? handler(response) : {message: response.statusText}); 265 | return $q.reject(response); 266 | } 267 | }]) 268 | 269 | ; 270 | 271 | /** 272 | * A callback function used by the context to create resources. Will be called 273 | * with the 'new' operator, so can be a constructor. 274 | * 275 | * @callback ResourceFactory 276 | * @returns {Resource} the created resource 277 | * @see ResourceContext 278 | */ 279 | -------------------------------------------------------------------------------- /src/context.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('ResourceContext', function () { 4 | beforeEach(module('hypermedia')); 5 | 6 | 7 | // Setup 8 | 9 | var $httpBackend, $q, ResourceContext, context, resource; 10 | var problemJson = 'application/problem+json'; 11 | 12 | beforeEach(inject(function (_$httpBackend_, _$q_, _ResourceContext_) { 13 | $httpBackend = _$httpBackend_; 14 | $q = _$q_; 15 | ResourceContext = _ResourceContext_; 16 | context = new ResourceContext(); 17 | resource = context.get('http://example.com'); 18 | })); 19 | 20 | afterEach(function () { 21 | $httpBackend.verifyNoOutstandingExpectation(); 22 | $httpBackend.verifyNoOutstandingRequest(); 23 | }); 24 | 25 | 26 | // Tests 27 | 28 | it('invokes error handler for content type', function () { 29 | var spy = jasmine.createSpy('spy').and.callFake(function () { 30 | return {}; 31 | }); 32 | ResourceContext.registerErrorHandler(problemJson, spy); 33 | 34 | context.httpGet(resource); 35 | $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) 36 | .respond(500, null, {'Content-Type': problemJson}); 37 | $httpBackend.flush(); 38 | 39 | expect(spy).toHaveBeenCalled(); 40 | }); 41 | 42 | it('rejects response with error if no matching error handler', function () { 43 | var statusText = 'Validation error'; 44 | var promiseResult = null; 45 | var spy = jasmine.createSpy('spy'); 46 | 47 | ResourceContext.registerErrorHandler('application/json', spy); 48 | context.httpGet(resource).catch(function (result) { 49 | promiseResult = result; 50 | }); 51 | $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) 52 | .respond(500, {}, {'Content-Type': problemJson}, statusText); 53 | $httpBackend.flush(); 54 | 55 | expect(spy).not.toHaveBeenCalled(); 56 | expect(promiseResult.error.message).toBe(statusText); 57 | expect(promiseResult.status).toBe(500); 58 | }); 59 | 60 | it('invokes default error handler for content type "application/vnd.error+json"', function () { 61 | var promiseResult = null; 62 | var msg = 'Validatie fout'; 63 | context.httpGet(resource).catch(function (result) { 64 | promiseResult = result; 65 | }); 66 | $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) 67 | .respond(500, {message: msg}, {'Content-Type': 'application/vnd.error+json'}); 68 | $httpBackend.flush(); 69 | 70 | expect(promiseResult.error).toBeDefined(); 71 | expect(promiseResult.error.message).toBe(msg); 72 | }); 73 | 74 | it('creates unique resources', function () { 75 | expect(context.get('http://example.com')).toBe(resource); 76 | expect(context.get('http://example.com/other')).not.toBe(resource); 77 | }); 78 | 79 | it('adds resource aliases', function () { 80 | context.addAlias('http://example.com/other', 'http://example.com'); 81 | expect(context.get('http://example.com')).toBe(resource); 82 | expect(context.get('http://example.com/other')).toBe(resource); 83 | }); 84 | 85 | it('can disable resource aliases', function () { 86 | context.enableAliases = false; 87 | expect(function () { 88 | context.addAlias('http://example.com/other', 'http://example.com'); 89 | }).toThrowError("Resource aliases not enabled"); 90 | }); 91 | 92 | it('copies resources from another context', function () { 93 | resource.$links.profile = 'http://example.com/profile'; 94 | resource.name = 'John'; 95 | 96 | var context2 = new ResourceContext(); 97 | var resource2 = context2.copy(resource); 98 | 99 | expect(resource2).not.toBe(resource); 100 | expect(resource2.$uri).toBe(resource.$uri); 101 | expect(resource2.name).toBe('John'); 102 | expect(resource2.$links.profile).toBe('http://example.com/profile'); 103 | }); 104 | 105 | it('performs HTTP GET requests', function () { 106 | var promiseResult = null; 107 | context.httpGet(resource).then(function (result) { 108 | promiseResult = result; 109 | }); 110 | $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) 111 | .respond('{"name": "John"}', {'Content-Type': 'application/json'}); 112 | $httpBackend.flush(); 113 | expect(promiseResult).toBe(resource); 114 | expect(resource.name).toBe('John'); 115 | expect(resource.$syncTime / 100).toBeCloseTo(Date.now() / 100, 0); 116 | }); 117 | 118 | it('converts content type profile parameter to link', function () { 119 | var promiseResult = null; 120 | context.httpGet(resource).then(function (result) { 121 | promiseResult = result; 122 | }); 123 | $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) 124 | .respond('{"name": "John"}', {'Content-Type': 'application/json; profile="http://example.com/profile"'}); 125 | $httpBackend.flush(); 126 | expect(promiseResult).toBe(resource); 127 | expect(resource.name).toBe('John'); 128 | expect(resource.$links.profile).toEqual({href: 'http://example.com/profile'}); 129 | expect(resource.$syncTime / 100).toBeCloseTo(Date.now() / 100, 0); 130 | }); 131 | 132 | it('performs HTTP PUT requests', function () { 133 | var promiseResult = null; 134 | context.httpPut(resource).then(function (result) { 135 | promiseResult = result; 136 | }); 137 | $httpBackend.expectPUT(resource.$uri, {}, 138 | {'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json'}) 139 | .respond(204); 140 | $httpBackend.flush(); 141 | expect(promiseResult).toBe(resource); 142 | expect(resource.$syncTime / 100).toBeCloseTo(Date.now() / 100, 0); 143 | }); 144 | 145 | it('performs HTTP PATCH requests', function () { 146 | var promiseResult = null; 147 | var data = {}; 148 | context.httpPatch(resource, data).then(function (result) { 149 | promiseResult = result; 150 | }); 151 | $httpBackend.expectPATCH(resource.$uri, data, 152 | {'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/merge-patch+json'}) 153 | .respond(204); 154 | $httpBackend.flush(); 155 | expect(promiseResult).toBe(resource); 156 | expect(resource.$syncTime / 100).toBeCloseTo(Date.now() / 100, 0); 157 | }); 158 | 159 | it('performs HTTP DELETE requests', function () { 160 | var promiseResult = null; 161 | resource.$syncTime = 1; 162 | context.httpDelete(resource).then(function (result) { 163 | promiseResult = result; 164 | }); 165 | $httpBackend.expectDELETE(resource.$uri).respond(204); 166 | $httpBackend.flush(); 167 | expect(promiseResult).toBe(resource); 168 | expect(resource.$syncTime).toBeNull(); 169 | expect(context.get(resource.$uri)).not.toBe(resource); 170 | }); 171 | 172 | it('performs HTTP POST requests', function () { 173 | var promiseResult = null; 174 | resource.$syncTime = 1; 175 | var promise = context.httpPost(resource, 'Test', {'Accept': '*/*', 'Content-Type': 'text/plain'}); 176 | promise.then(function (result) { 177 | promiseResult = result; 178 | }); 179 | $httpBackend.expectPOST(resource.$uri, 'Test', {'Accept': '*/*', 'Content-Type': 'text/plain'}).respond(204); 180 | $httpBackend.flush(); 181 | expect(promiseResult.status).toBe(204); 182 | expect(resource.$syncTime).toBe(1); 183 | }); 184 | 185 | it('performs HTTP POST requests without headers', function () { 186 | var promiseResult = null; 187 | resource.$syncTime = 1; 188 | var promise = context.httpPost(resource, 'Test'); 189 | promise.then(function (result) { 190 | promiseResult = result; 191 | }); 192 | $httpBackend.expectPOST(resource.$uri, 'Test').respond(204); 193 | $httpBackend.flush(); 194 | expect(promiseResult.status).toBe(204); 195 | expect(resource.$syncTime).toBe(1); 196 | }); 197 | 198 | it('counts the number of busy requests', function () { 199 | expect(ResourceContext.busyRequests).toBe(0); 200 | context.httpPut(resource); 201 | expect(ResourceContext.busyRequests).toBe(1); 202 | context.httpPut(resource); 203 | expect(ResourceContext.busyRequests).toBe(2); 204 | $httpBackend.whenPUT(resource.$uri).respond(204); 205 | $httpBackend.flush(); 206 | expect(ResourceContext.busyRequests).toBe(0); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /src/halresource.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('hypermedia') 4 | 5 | /** 6 | * @ngdoc type 7 | * @name HalResource 8 | * @description 9 | * 10 | * HAL resource. 11 | */ 12 | .factory('HalResource', ['$log', 'HypermediaUtil', 'Resource', function ($log, HypermediaUtil, Resource) { 13 | var forArray = HypermediaUtil.forArray; 14 | 15 | /** 16 | * HAL resource. 17 | * 18 | * @constructor 19 | * @param {string} uri the resource URI 20 | * @param {ResourceContext} context the context object 21 | */ 22 | function HalResource(uri, context) { 23 | return Resource.call(this, uri, context); 24 | } 25 | 26 | // Prototype properties 27 | HalResource.prototype = Object.create(Resource.prototype, { 28 | constructor: {value: HalResource} 29 | }); 30 | 31 | HypermediaUtil.defineProperties(HalResource.prototype, { 32 | /** 33 | * Create a $http GET request configuration object. 34 | * 35 | * @function 36 | * @returns {object} 37 | */ 38 | $getRequest: {value: function () { 39 | return { 40 | method: 'get', 41 | url: this.$uri, 42 | headers: {'Accept': 'application/hal+json'} 43 | }; 44 | }}, 45 | 46 | /** 47 | * Update the resource with new data. 48 | * 49 | * @function 50 | * @param {object} data 51 | * @param {object} [links] 52 | * @returns all updated resources 53 | */ 54 | $update: {value: function (data, links) { 55 | links = links || {}; 56 | return extractAndUpdateResources(data, links, this, this); 57 | }} 58 | }); 59 | 60 | return HalResource; 61 | 62 | 63 | /** 64 | * Recursively extract embedded resources and update them in the context, then update the resource itself. 65 | * 66 | * @param {object} data 67 | * @param {object} [links] 68 | * @param {Resource} rootResource 69 | * @param {Resource} resource 70 | */ 71 | function extractAndUpdateResources(data, links, rootResource, resource) { 72 | var resources = []; 73 | 74 | var selfHref = ((data._links || {}).self || {}).href; 75 | if (!selfHref) { 76 | throw new Error('Self link href expected but not found'); 77 | } 78 | 79 | // Extract links 80 | angular.extend(links, data._links); 81 | delete data._links; 82 | 83 | // Extract and update embedded resources 84 | Object.keys(data._embedded || {}).forEach(function (rel) { 85 | var embeds = data._embedded[rel]; 86 | 87 | // Add link to embedded resource if missing 88 | if (!(rel in links)) { 89 | links[rel] = forArray(embeds, function (embedded) { 90 | return {href: embedded._links.self.href}; 91 | }); 92 | } 93 | // Recurse into embedded resource 94 | forArray(embeds, function (embedded) { 95 | resources = resources.concat(extractAndUpdateResources(embedded, {}, rootResource, null)); 96 | }); 97 | }); 98 | delete data._embedded; 99 | 100 | // Update resource 101 | if (!resource) resource = rootResource.$context.get(links.self.href, rootResource.constructor); 102 | Resource.prototype.$update.call(resource, data, links); 103 | resources.push(resource); 104 | 105 | return resources; 106 | } 107 | }]) 108 | 109 | ; 110 | -------------------------------------------------------------------------------- /src/halresource.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('HalResource', function () { 4 | beforeEach(module('hypermedia')); 5 | 6 | // Setup 7 | 8 | var $log, $q, $rootScope, HalResource, mockContext, uri, resource; 9 | 10 | beforeEach(inject(function (_$log_, _$q_, _$rootScope_, _HalResource_) { 11 | $log = _$log_; 12 | $q = _$q_; 13 | $rootScope = _$rootScope_; 14 | HalResource = _HalResource_; 15 | mockContext = jasmine.createSpyObj('mockContext', ['get', 'httpGet', 'httpPut', 'httpDelete', 'httpPost']); 16 | uri = 'http://example.com'; 17 | resource = new HalResource(uri, mockContext); 18 | })); 19 | 20 | // Tests 21 | 22 | it('is initialized correctly', function () { 23 | expect(resource.$uri).toBe(uri); 24 | expect(resource.$context).toBe(mockContext); 25 | expect(resource.$links).toEqual({self: {href: uri}}); 26 | expect(resource.$syncTime).toBeNull(); 27 | expect(resource.$profile).toBeNull(); 28 | }); 29 | 30 | // HTTP 31 | 32 | it('creates HTTP GET request for HAL data', function () { 33 | expect(resource.$getRequest()).toEqual({ 34 | method: 'get', 35 | url: 'http://example.com', 36 | headers: {Accept: 'application/hal+json'} 37 | }); 38 | }); 39 | 40 | it('creates HTTP PUT request with JSON data', function () { 41 | expect(resource.$putRequest()).toEqual({ 42 | method: 'put', 43 | url: 'http://example.com', 44 | data: resource, 45 | headers: {'Content-Type': 'application/json'} 46 | }); 47 | }); 48 | 49 | // Updates 50 | 51 | it('requires a link with the "self" relation in the data', function () { 52 | expect(function () { 53 | resource.$update({foo: 'bar'}); 54 | }).toThrowError("Self link href expected but not found"); 55 | }); 56 | 57 | it('extracts links', function () { 58 | mockContext.get.and.returnValue(resource); 59 | var links = { 60 | self: {href: 'http://example.com'}, 61 | profile: {href: 'http://example.com/profile'} 62 | }; 63 | resource.$update({ 64 | foo: 'bar', 65 | _links: links 66 | }); 67 | expect(resource.$links).toEqual(links); 68 | expect(resource._links).toBeUndefined(); 69 | }); 70 | 71 | it('extracts and links embedded resources', function () { 72 | var resource1 = new HalResource('http://example.com/1', mockContext); 73 | mockContext.get.and.callFake(function (uri) { 74 | switch (uri) { 75 | case resource.$uri: return resource; 76 | case resource1.$uri: return resource1; 77 | } 78 | }); 79 | 80 | resource.$update({ 81 | _links: { 82 | self: {href: 'http://example.com'}, 83 | profile: {href: 'http://example.com/profile'} 84 | }, 85 | _embedded: { 86 | 'next': { 87 | foo: 'bar', 88 | _links: { 89 | self: {href: 'http://example.com/1'} 90 | } 91 | } 92 | } 93 | }); 94 | expect(resource.$links.next.href).toBe(resource1.$uri); 95 | expect(resource1.foo).toBe('bar'); 96 | expect(resource._embedded).toBeUndefined(); 97 | }); 98 | 99 | it('extracts and links embedded resources for custom resource type', function () { 100 | var MyResource = function (uri, context) { 101 | return HalResource.call(this, uri, context); 102 | }; 103 | MyResource.prototype = Object.create(HalResource.prototype, { 104 | constructor: {value: MyResource} 105 | }); 106 | var resource2 = new MyResource(uri, mockContext), 107 | resource3 = new MyResource('http://example.com/1', mockContext); 108 | mockContext.get.and.callFake(function (uri) { 109 | switch (uri) { 110 | case resource2.$uri: return resource2; 111 | case resource3.$uri: return resource3; 112 | } 113 | }); 114 | 115 | resource2.$update({ 116 | _links: { 117 | self: {href: 'http://example.com'}, 118 | profile: {href: 'http://example.com/profile'} 119 | }, 120 | _embedded: { 121 | 'next': { 122 | foo: 'bar', 123 | _links: { 124 | self: {href: 'http://example.com/1'} 125 | } 126 | } 127 | } 128 | }); 129 | 130 | expect(mockContext.get).toHaveBeenCalledWith(resource3.$uri, MyResource); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/resource.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('hypermedia') 4 | 5 | /** 6 | * @ngdoc type 7 | * @name Resource 8 | * @description 9 | * 10 | * Hypermedia resource. 11 | */ 12 | .factory('Resource', ['$log', '$q', 'HypermediaUtil', function ($log, $q, HypermediaUtil) { 13 | var forArray = HypermediaUtil.forArray; 14 | 15 | var registeredProfiles = {}; 16 | 17 | /** 18 | * Resource. 19 | * 20 | * @constructor 21 | * @param {string} uri the resource URI 22 | * @param {ResourceContext} context the resource context 23 | */ 24 | function Resource(uri, context) { 25 | // This constructor does not use the automatically created object but instantiate from a subclass instead 26 | 27 | // Intermediate prototype to add profile-specific properties to 28 | var prototype = Object.create(Object.getPrototypeOf(this)); 29 | 30 | // Current profile(s) 31 | var profile = null; 32 | 33 | // Instantiated object 34 | return Object.create(prototype, { 35 | 36 | /** 37 | * The resource URI. 38 | * 39 | * @property {string} 40 | */ 41 | $uri: {value: uri}, 42 | 43 | /** 44 | * The resource context. Can be used to get related resources. 45 | * 46 | * @property {ResourceContext} 47 | */ 48 | $context: {value: context}, 49 | 50 | /** 51 | * Links to other resources. 52 | * 53 | * @property {object} 54 | */ 55 | $links: {value: { 56 | self: { 57 | href: uri 58 | } 59 | }, writable: true}, 60 | 61 | /** 62 | * The timestamp of the last successful GET or PUT request. 63 | * 64 | * @property {number} Resource.syncTime 65 | * @see ResourceContext#markSynced 66 | */ 67 | $syncTime: {value: null, writable: true}, 68 | 69 | /** 70 | * The resource profile URI(s). If profile properties have been registered for this URI (using 71 | * HalContextProvider.registerProfile or ResourceContext.registerProfile), the properties will be defined on the 72 | * resource. 73 | * 74 | * Setting the profile to 'undefined' or 'null' will remove the profile. 75 | * 76 | * @property {string|string[]} 77 | */ 78 | $profile: { 79 | get: function () { 80 | return profile; 81 | }, 82 | set: function (value) { 83 | // Remove old profile properties 84 | if (profile) { 85 | var oldProfiles = angular.isArray(profile) ? profile : [profile]; 86 | oldProfiles.forEach(function (profile) { 87 | var properties = registeredProfiles[profile] || {}; 88 | Object.keys(properties).forEach(function (key) { 89 | delete prototype[key]; 90 | }); 91 | }); 92 | } 93 | 94 | // Apply new profile properties 95 | if (value) { 96 | var newProfiles = angular.isArray(value) ? value : [value]; 97 | newProfiles.forEach(function (profile) { 98 | var properties = registeredProfiles[profile] || {}; 99 | Object.defineProperties(prototype, properties); 100 | }); 101 | } 102 | 103 | profile = value; 104 | } 105 | } 106 | }); 107 | } 108 | 109 | // Prototype properties 110 | Resource.prototype = Object.create(Object.prototype, { 111 | constructor: {value: Resource}, 112 | 113 | /** 114 | * Whether the resource was synchronized with the server. 115 | * 116 | * @property {boolean} 117 | */ 118 | $isSynced: {get: function () { 119 | return !!this.$syncTime; 120 | }} 121 | }); 122 | 123 | HypermediaUtil.defineProperties(Resource.prototype, { 124 | /** 125 | * Resolve the href of a property. 126 | * 127 | * @function 128 | * @param {string} prop the property name 129 | * @param {object} [vars] URI template variables 130 | * @returns {string|string[]} the link href or hrefs 131 | */ 132 | $propHref: {value: function (prop, vars) { 133 | return forArray(this[prop], function (uri) { 134 | if (vars) uri = new UriTemplate(uri).fillFromObject(vars); 135 | return uri; 136 | }); 137 | }}, 138 | 139 | /** 140 | * Follow a property relation to another resource. 141 | * 142 | * @function 143 | * @param {string} prop the property name 144 | * @param {object} [vars] URI template variables 145 | * @param {ResourceFactory} [factory] the factory for creating the resource 146 | * @returns {Resource|Resource[]} the linked resource or resources 147 | */ 148 | $propRel: {value: function (prop, vars, factory) { 149 | if (angular.isFunction(vars)) { 150 | factory = vars; 151 | vars = undefined; 152 | } 153 | return forArray(this.$propHref(prop, vars), function (uri) { 154 | return this.$context.get(uri, factory); 155 | }, this); 156 | }}, 157 | 158 | /** 159 | * Resolve the href of a link relation. 160 | * 161 | * @function 162 | * @param {string} rel the link relation 163 | * @param {object} [vars] URI template variables 164 | * @returns {string|string[]} the link href or hrefs 165 | */ 166 | $linkHref: {value: function (rel, vars) { 167 | var templated = false; 168 | var nonTemplated = false; 169 | var deprecation = {}; 170 | 171 | var linkHrefs = forArray(this.$links[rel], function (link) { 172 | if ('templated' in link) templated = true; 173 | if (!('templated' in link)) nonTemplated = true; 174 | if ('deprecation' in link) deprecation[link.deprecation] = true; 175 | 176 | var uri = link.href; 177 | if (vars) uri = new UriTemplate(uri).fillFromObject(vars); 178 | return uri; 179 | }, this); 180 | 181 | if (templated && !vars) { 182 | $log.warn("Following templated link relation '" + rel + "' without variables"); 183 | } 184 | if (nonTemplated && vars) { 185 | $log.warn("Following non-templated link relation '" + rel + "' with variables"); 186 | } 187 | var deprecationUris = Object.keys(deprecation); 188 | if (deprecationUris.length > 0) { 189 | $log.warn("Following deprecated link relation '" + rel + "': " + deprecationUris.join(', ')); 190 | } 191 | 192 | return linkHrefs; 193 | }}, 194 | 195 | /** 196 | * Follow a link relation to another resource. 197 | * 198 | * @function 199 | * @param {string} rel the link relation 200 | * @param {object} [vars] URI template variables 201 | * @param {ResourceFactory} [factory] the factory for creating the related resource 202 | * @returns {Resource|Resource[]} the linked resource or resources 203 | */ 204 | $linkRel: {value: function (rel, vars, factory) { 205 | if (angular.isFunction(vars)) { 206 | factory = vars; 207 | vars = undefined; 208 | } 209 | return forArray(this.$linkHref(rel, vars), function (uri) { 210 | return this.$context.get(uri, factory); 211 | }, this); 212 | }}, 213 | 214 | /** 215 | * Perform an HTTP GET request if the resource is not synchronized or if 216 | * the resource was synced before timestamp passed as argument. 217 | * 218 | * @function 219 | * @param {number} [ts] timestamp to check against 220 | * @returns a promise that is resolved to the resource 221 | * @see Resource#$syncTime 222 | */ 223 | $load: {value: function (ts) { 224 | if (!this.$syncTime || (ts && this.$syncTime < ts)) { 225 | return this.$context.httpGet(this); 226 | } else { 227 | return $q.when(this); 228 | } 229 | }}, 230 | 231 | /** 232 | * Perform an HTTP GET request if the resource was synced before 233 | * the timestamp passed as argument. 234 | * 235 | * @function 236 | * @param {number} [ts] timestamp to check against; default: Date.now() 237 | * @returns a promise that is resolved to the resource 238 | * @see Resource#$syncTime 239 | */ 240 | $refresh: {value: function (ts) { 241 | if (!ts) ts = Date.now(); 242 | return this.$load(ts); 243 | }}, 244 | 245 | /** 246 | * Load all resources reachable from a resource using one or more paths. 247 | * A path is on object hierarchy containing property or relation names. 248 | * If the name matches a property it is loaded, otherwise it is 249 | * interpreted as a link relation. 250 | * 251 | * Examples: 252 | * context.loadPaths(resource, {team_url: {}}) 253 | * context.loadPaths(resource, {'http://example.com/owner': {}}) 254 | * context.loadPaths(resource, { 255 | * home: { 256 | * address: {} 257 | * } 258 | * }) 259 | * context.loadPaths(resource, { 260 | * 'ex:car': {}, 261 | * 'ex:friends': { 262 | * 'ex:car': {} 263 | * } 264 | * }) 265 | * 266 | * @function 267 | * @param {Resource} resource 268 | * @param {object} paths 269 | * @param {number} [ts] timestamp to check against 270 | * @return {Promise} a promise that resolves to the resource once all 271 | * paths have been loaded 272 | * @see {@link #$load} 273 | */ 274 | $loadPaths: {value: function (paths, ts, path_prefix, root_uri) { 275 | var self = this; 276 | if (!path_prefix) { 277 | path_prefix = []; 278 | root_uri = self.$uri; 279 | } 280 | return self.$load(ts).then(function () { 281 | var promises = []; 282 | Object.keys(paths).forEach(function (key) { 283 | var full_path = path_prefix.concat(key); 284 | var uris = self.$propHref(key); 285 | if (!uris) uris = self.$linkHref(key); 286 | if (!uris) { 287 | $log.warn('Warning while loading path "' + full_path.join('.') + '" from resource "' + root_uri + '": ' + 288 | 'property or link "' + key + '" not found on resource "' + self.$uri + '"'); 289 | return; 290 | } 291 | 292 | uris = angular.isArray(uris) ? uris : [uris]; 293 | uris.forEach(function (uri) { 294 | var related = (typeof uri === 'string') ? self.$context.get(uri) : uri; 295 | promises.push(related.$loadPaths(paths[key], ts, full_path, root_uri)); 296 | }); 297 | }); 298 | return $q.all(promises); 299 | }).then(function () { 300 | return self; 301 | }); 302 | }}, 303 | 304 | /** 305 | * Refresh all resources reachable from a resource using one or more paths. 306 | * 307 | * @function 308 | * @param {Resource} resource 309 | * @param {object} paths 310 | * @param {number} [ts] timestamp to check against; default: Date.now() 311 | * @return {Promise} a promise that resolves to the resource once all 312 | * paths have been loaded 313 | * @see {@link #$loadPaths} 314 | */ 315 | $refreshPaths: {value: function (paths, ts) { 316 | if (!ts) ts = Date.now(); 317 | return this.$loadPaths(paths, ts); 318 | }}, 319 | 320 | /** 321 | * Create a $http GET request configuration object. 322 | * 323 | * @function 324 | * @returns {object} 325 | */ 326 | $getRequest: {value: function () { 327 | return { 328 | method: 'get', 329 | url: this.$uri, 330 | headers: {'Accept': 'application/json'} 331 | }; 332 | }}, 333 | 334 | /** 335 | * Perform an HTTP GET request. 336 | * 337 | * @function 338 | * @returns a promise that is resolved to the resource 339 | */ 340 | $get: {value: function () { 341 | return this.$context.httpGet(this); 342 | }}, 343 | 344 | /** 345 | * Create a $http PUT request configuration object. 346 | * 347 | * @function 348 | * @returns {object} 349 | */ 350 | $putRequest: {value: function () { 351 | return { 352 | method: 'put', 353 | url: this.$uri, 354 | data: this, 355 | headers: {'Content-Type': 'application/json'} 356 | }; 357 | }}, 358 | 359 | /** 360 | * Perform an HTTP PUT request with the resource state. 361 | * 362 | * @function 363 | * @returns a promise that is resolved to the resource 364 | */ 365 | $put: {value: function () { 366 | return this.$context.httpPut(this); 367 | }}, 368 | 369 | /** 370 | * Create a $http PATCH request configuration object. 371 | * 372 | * @function 373 | * @returns {object} 374 | */ 375 | $patchRequest: {value: function (data) { 376 | return { 377 | method: 'patch', 378 | url: this.$uri, 379 | data: data, 380 | headers: {'Content-Type': 'application/merge-patch+json'} 381 | }; 382 | }}, 383 | 384 | /** 385 | * Perform an HTTP PATCH request with the resource state. 386 | * 387 | * @function 388 | * @returns a promise that is resolved to the resource 389 | */ 390 | $patch: {value: function (data) { 391 | return this.$context.httpPatch(this, data); 392 | }}, 393 | 394 | /** 395 | * Create a $http DELETE request configuration object. 396 | * 397 | * @function 398 | * @returns {object} 399 | */ 400 | $deleteRequest: {value: function () { 401 | return { 402 | method: 'delete', 403 | url: this.$uri 404 | }; 405 | }}, 406 | 407 | /** 408 | * Perform an HTTP DELETE request. 409 | * 410 | * @function 411 | * @returns a promise that is resolved to the resource 412 | */ 413 | $delete: {value: function () { 414 | return this.$context.httpDelete(this); 415 | }}, 416 | 417 | /** 418 | * Create a $http POST request configuration object. 419 | * 420 | * @function 421 | * @param {*} data request body 422 | * @param {object} [headers] request headers 423 | * @param {ConfigHttp} [callback] a function that changes the $http request config 424 | * @returns {object} 425 | */ 426 | $postRequest: {value: function (data, headers, callback) { 427 | callback = callback || angular.identity; 428 | return callback({ 429 | method: 'post', 430 | url: this.$uri, 431 | data: data, 432 | headers: headers || {} 433 | }); 434 | }}, 435 | 436 | /** 437 | * Perform an HTTP POST request. 438 | * 439 | * @function 440 | * @param {*} data request body 441 | * @param {object} [headers] request headers 442 | * @param {ConfigHttp} [callback] a function that changes the $http request config 443 | * @returns a promise that is resolved to the response 444 | */ 445 | $post: {value: function (data, headers, callback) { 446 | return this.$context.httpPost(this, data, headers, callback); 447 | }}, 448 | 449 | /** 450 | * Update the resource with new data by clearing all existing properties 451 | * and then copying all properties from 'data'. 452 | * 453 | * @function 454 | * @param {object} data 455 | * @param {object} [links] 456 | * @returns the resource 457 | */ 458 | $update: {value: function (data, links) { 459 | links = links || {}; 460 | var selfHref = ((links || {}).self || {}).href; 461 | if (selfHref && selfHref !== this.$uri) { 462 | if (this.$context.enableAliases) { 463 | this.$context.addAlias(selfHref, this.$uri); 464 | } else { 465 | throw new Error('Self link href differs: expected "' + this.$uri + '", was ' + 466 | angular.toJson(selfHref)); 467 | } 468 | } 469 | 470 | // Update resource 471 | Object.keys(this).forEach(function (key) { 472 | if (key.indexOf('$$') !== 0) { 473 | delete this[key]; 474 | } 475 | }, this); 476 | Object.keys(data).forEach(function (key) { 477 | if (key.indexOf('$$') !== 0) { 478 | this[key] = data[key]; 479 | } 480 | }, this); 481 | 482 | this.$links = {self: {href: this.$uri}}; // Add default self link 483 | angular.extend(this.$links, links); 484 | 485 | // Optionally apply profile(s) 486 | var profileUris = forArray(links.profile, function (link) { 487 | return link.href; 488 | }); 489 | if (profileUris) this.$profile = profileUris; 490 | 491 | return this; 492 | }}, 493 | 494 | /** 495 | * Merges the resource with new data following algorithm defined 496 | * in JSON Merge Patch specification (RFC 7386, https://tools.ietf.org/html/rfc7386). 497 | * 498 | * @function 499 | * @param {object} data 500 | * @param {object} [links] 501 | * @returns the resource 502 | */ 503 | $merge: {value: function (data) { 504 | var mergePatch = function (target, patch) { 505 | if (!angular.isObject(patch) || patch === null || Array.isArray(patch)) { 506 | return patch; 507 | } 508 | 509 | if (!angular.isObject(target) || target === null || Array.isArray(target)) { 510 | target = {}; 511 | } 512 | 513 | Object.keys(patch).forEach(function (key) { 514 | var value = patch[key]; 515 | if (value === null) { 516 | delete target[key]; 517 | } else { 518 | target[key] = mergePatch(target[key], value); 519 | } 520 | }); 521 | 522 | return target; 523 | }; 524 | 525 | return mergePatch(this, data); 526 | }} 527 | }); 528 | 529 | // Class properties 530 | HypermediaUtil.defineProperties(Resource, { 531 | 532 | /** 533 | * Register a profile. 534 | * 535 | * @function 536 | * @param {string} profile the profile URI 537 | * @param {object} properties a properties object as used in 'Object.defineProperties()' 538 | */ 539 | registerProfile: {value: function (profile, properties) { 540 | // Make sure properties can be removed when applying a different profile 541 | var props = angular.copy(properties); 542 | angular.forEach(props, function (prop) { 543 | prop.configurable = true; 544 | }); 545 | registeredProfiles[profile] = props; 546 | }}, 547 | 548 | /** 549 | * Register profiles. 550 | * 551 | * @function 552 | * @param {object} profiles an object mapping profile URIs to properties objects as used in 553 | * 'Object.defineProperties()' 554 | */ 555 | registerProfiles: {value: function (profiles) { 556 | angular.forEach(profiles, function (properties, profile) { 557 | Resource.registerProfile(profile, properties); 558 | }); 559 | }} 560 | }); 561 | 562 | return Resource; 563 | }]) 564 | 565 | ; 566 | 567 | /** 568 | * A callback function used to change a $http config object. 569 | * 570 | * @callback ConfigHttp 571 | * @param {object} config the $http config object 572 | * @returns {object} the $http config object 573 | */ 574 | -------------------------------------------------------------------------------- /src/resource.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Resource', function () { 4 | beforeEach(module('hypermedia')); 5 | 6 | // Setup 7 | 8 | var $log, $q, $rootScope, Resource, mockContext, uri, resource; 9 | 10 | beforeEach(inject(function (_$log_, _$q_, _$rootScope_, _Resource_) { 11 | $log = _$log_; 12 | $q = _$q_; 13 | $rootScope = _$rootScope_; 14 | Resource = _Resource_; 15 | mockContext = jasmine.createSpyObj('mockContext', ['get', 'addAlias', 'httpGet', 'httpPut', 'httpDelete', 'httpPost', 'httpPatch']); 16 | uri = 'http://example.com'; 17 | resource = new Resource(uri, mockContext); 18 | })); 19 | 20 | // Tests 21 | 22 | it('is initialized correctly', function () { 23 | expect(resource.$uri).toBe(uri); 24 | expect(resource.$context).toBe(mockContext); 25 | expect(resource.$links).toEqual({self: {href: uri}}); 26 | expect(resource.$syncTime).toBeNull(); 27 | expect(resource.$isSynced).toBeFalsy(); 28 | expect(resource.$profile).toBeNull(); 29 | }); 30 | 31 | it('sets $isSynced if $syncTime is set', function () { 32 | resource.$syncTime = Date.now(); 33 | expect(resource.$isSynced).toBeTruthy(); 34 | }); 35 | 36 | // Profiles 37 | 38 | it('adds properties by setting $profile', function () { 39 | Resource.registerProfile('profile', {test: {value: true}}); 40 | resource.$profile = 'profile'; 41 | expect(resource.test).toBe(true); 42 | }); 43 | 44 | it('adds methods by setting $profile', function () { 45 | Resource.registerProfile('profile', {test: {value: function () { 46 | return 'test' + this.number; 47 | }}}); 48 | resource.$profile = 'profile'; 49 | resource.number = 1; 50 | expect(resource.test()).toBe('test1'); 51 | }); 52 | 53 | it('accepts multiple profiles', function () { 54 | Resource.registerProfiles({ 55 | profile1: {test1: {value: true}}, 56 | profile2: {test2: {value: true}} 57 | }); 58 | resource.$profile = ['profile1', 'profile2']; 59 | expect(resource.test1).toBe(true); 60 | expect(resource.test2).toBe(true); 61 | }); 62 | 63 | it('can switch profiles', function () { 64 | Resource.registerProfiles({ 65 | profile1: {test1: {value: true}}, 66 | profile2: {test2: {value: true}} 67 | }); 68 | 69 | resource.$profile = 'profile1'; 70 | expect(resource.test1).toBe(true); 71 | expect(resource.test2).toBeUndefined(); 72 | 73 | resource.$profile = 'profile2'; 74 | expect(resource.test1).toBeUndefined(); 75 | expect(resource.test2).toBe(true); 76 | }); 77 | 78 | // Property hrefs 79 | 80 | it('gets hrefs in properties', function () { 81 | resource.test_href = 'http://example.com/test'; 82 | expect(resource.$propHref('test_href')).toBe('http://example.com/test'); 83 | }); 84 | 85 | it('resolves templated hrefs in properties', function () { 86 | resource.test_href = 'http://example.com/{id}'; 87 | var result = resource.$propHref('test_href', {id: 'test'}); 88 | expect(result).toBe('http://example.com/test'); 89 | }); 90 | 91 | it('resolves arrays of templated hrefs in properties', function () { 92 | resource.test_href = ['http://example.com/1/{id}', 'http://example.com/2/{id}']; 93 | var result = resource.$propHref('test_href', {id: 'test'}); 94 | expect(result).toEqual(['http://example.com/1/test', 'http://example.com/2/test']); 95 | }); 96 | 97 | it('returns undefined getting hrefs if property does not exist', function () { 98 | expect(resource.$propHref('test_href')).toBeUndefined(); 99 | }); 100 | 101 | it('follows hrefs in properties', function () { 102 | var resource2 = new Resource('http://example.com/test', mockContext); 103 | mockContext.get.and.returnValue(resource2); 104 | 105 | resource.test_href = 'http://example.com/test'; 106 | expect(resource.$propRel('test_href')).toBe(resource2); 107 | expect(mockContext.get).toHaveBeenCalledWith('http://example.com/test', undefined); 108 | }); 109 | 110 | it('follows templated hrefs in properties', function () { 111 | var resource2 = new Resource('http://example.com/test', mockContext); 112 | mockContext.get.and.returnValue(resource2); 113 | 114 | resource.test_href = 'http://example.com/{id}'; 115 | expect(resource.$propRel('test_href', {id: 'test'})).toBe(resource2); 116 | expect(mockContext.get).toHaveBeenCalledWith('http://example.com/test', undefined); 117 | }); 118 | 119 | it('follows arrays of templated hrefs in properties', function () { 120 | var resource1 = new Resource('http://example.com/1/test', mockContext); 121 | var resource2 = new Resource('http://example.com/2/test', mockContext); 122 | mockContext.get.and.callFake(function (uri) { 123 | if (uri == 'http://example.com/1/test') { 124 | return resource1; 125 | } else { 126 | return resource2; 127 | } 128 | }); 129 | 130 | resource.test_href = ['http://example.com/1/{id}', 'http://example.com/2/{id}']; 131 | expect(resource.$propRel('test_href', {id: 'test'})).toEqual([resource1, resource2]); 132 | expect(mockContext.get).toHaveBeenCalledWith('http://example.com/1/test', undefined); 133 | expect(mockContext.get).toHaveBeenCalledWith('http://example.com/2/test', undefined); 134 | }); 135 | 136 | it('returns undefined following hrefs if property does not exist', function () { 137 | expect(resource.$propRel('test_href')).toBeUndefined(); 138 | }); 139 | 140 | it('follow hrefs in properties with a factory', function () { 141 | var resource2 = new Resource('http://example.com/test', mockContext); 142 | mockContext.get.and.returnValue(resource2); 143 | 144 | resource.test_href = 'http://example.com/test'; 145 | expect(resource.$propRel('test_href', Resource)).toBe(resource2); 146 | expect(mockContext.get).toHaveBeenCalledWith('http://example.com/test', Resource); 147 | }); 148 | 149 | // Links 150 | 151 | it('resolves links', function () { 152 | resource.$links.example = {href: 'http://example.com/test'}; 153 | expect(resource.$linkHref('example')).toBe('http://example.com/test'); 154 | }); 155 | 156 | it('resolves templated links', function () { 157 | resource.$links.example = {href: 'http://example.com/{id}', templated: true}; 158 | var result = resource.$linkHref('example', {id: 'test'}); 159 | expect(result).toBe('http://example.com/test'); 160 | }); 161 | 162 | it('warns when resolving templated links without vars', function () { 163 | resource.$links.example = {href: 'http://example.com/{id}', templated: true}; 164 | resource.$linkHref('example'); 165 | expect($log.warn.logs).toEqual([["Following templated link relation 'example' without variables"]]); 166 | }); 167 | 168 | it('warns when resolving non-templated links with vars', function () { 169 | resource.$links.example = {href: 'http://example.com/test'}; 170 | resource.$linkHref('example', {foo: 'bar'}); 171 | expect($log.warn.logs).toEqual([["Following non-templated link relation 'example' with variables"]]); 172 | }); 173 | 174 | it('warns when resolving a deprecated link', function () { 175 | resource.$links.example = {href: 'http://example.com/test', deprecation: 'http://example.com/deprecation'}; 176 | resource.$linkHref('example'); 177 | expect($log.warn.logs).toEqual([["Following deprecated link relation 'example': http://example.com/deprecation"]]); 178 | }); 179 | 180 | it('resolves array links', function () { 181 | var href1 = 'http://example.com/1'; 182 | var href2 = 'http://example.com/2'; 183 | resource.$links.example = [{href: href1}, {href: href2}]; 184 | expect(resource.$linkHref('example')).toEqual([href1, href2]); 185 | }); 186 | 187 | it('follows links', function () { 188 | var resource1 = new Resource('http://example.com/1', mockContext); 189 | mockContext.get.and.returnValue(resource1); 190 | resource.$links.example = {href: resource1.$uri}; 191 | expect(resource.$linkRel('example')).toBe(resource1); 192 | expect(mockContext.get).toHaveBeenCalledWith(resource1.$uri, undefined); 193 | }); 194 | 195 | it('follows links with factory', function () { 196 | var resource1 = new Resource('http://example.com/1', mockContext); 197 | mockContext.get.and.returnValue(resource1); 198 | resource.$links.example = {href: resource1.$uri}; 199 | expect(resource.$linkRel('example', Resource)).toBe(resource1); 200 | expect(mockContext.get).toHaveBeenCalledWith(resource1.$uri, Resource); 201 | }); 202 | 203 | it('follows array links', function () { 204 | var resource1 = new Resource('http://example.com/1', mockContext); 205 | var resource2 = new Resource('http://example.com/2', mockContext); 206 | mockContext.get.and.callFake(function (uri) { 207 | if (uri == 'http://example.com/1') { 208 | return resource1; 209 | } else { 210 | return resource2; 211 | } 212 | }); 213 | 214 | resource.$links.example = [{href: resource1.$uri}, {href: resource2.$uri}]; 215 | expect(resource.$linkRel('example')).toEqual([resource1, resource2]); 216 | expect(mockContext.get).toHaveBeenCalledWith(resource1.$uri, undefined); 217 | expect(mockContext.get).toHaveBeenCalledWith(resource2.$uri, undefined); 218 | }); 219 | 220 | // Loading 221 | 222 | it('loads a resource if it has not been synced yet', function () { 223 | resource.$load(); 224 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 225 | }); 226 | 227 | it('loads a resource if it has been synced but stale', function () { 228 | resource.$syncTime = new Date('2016-01-01').getTime(); 229 | resource.$load(new Date('2016-01-02').getTime()); 230 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 231 | }); 232 | 233 | it('does not load a resource again if it has been synced before the load timestamp', function () { 234 | resource.$syncTime = new Date('2016-01-02').getTime(); 235 | resource.$load(new Date('2016-01-01').getTime()); 236 | expect(mockContext.httpGet).not.toHaveBeenCalled(); 237 | }); 238 | 239 | it('does not load a resource again if it has been synced', function () { 240 | resource.$syncTime = Date.now(); 241 | resource.$load(); 242 | expect(mockContext.httpGet).not.toHaveBeenCalled(); 243 | }); 244 | 245 | it('does not load a resource again if it has been synced and not stale', function () { 246 | resource.$syncTime = new Date('2016-01-02').getTime(); 247 | resource.$load(new Date('2016-01-01').getTime()); 248 | expect(mockContext.httpGet).not.toHaveBeenCalled(); 249 | }); 250 | 251 | it('loads paths of related resources', function (done) { 252 | var resource1 = new Resource('http://example.com/1', mockContext); 253 | var resource2 = new Resource('http://example.com/2', mockContext); 254 | var resource3 = new Resource('http://example.com/3', mockContext); 255 | mockContext.get.and.callFake(function (uri) { 256 | switch (uri) { 257 | case resource.$uri: return resource; 258 | case resource1.$uri: return resource1; 259 | case resource2.$uri: return resource2; 260 | case resource3.$uri: return resource3; 261 | } 262 | }); 263 | mockContext.httpGet.and.callFake(function (resource) { 264 | return $q.when(resource); 265 | }); 266 | 267 | resource.$links.step1 = {href: resource1.$uri}; 268 | resource1.step2 = resource2.$uri; 269 | resource2.step3 = resource3; 270 | 271 | resource.$loadPaths({step1: {step2: {}}}).then(function () { 272 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 273 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource1); 274 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource2); 275 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource3); 276 | done(); 277 | }); 278 | $rootScope.$digest(); 279 | }); 280 | 281 | it('loads paths of related resources with a timestamp', function (done) { 282 | var resource1 = new Resource('http://example.com/1', mockContext); 283 | var resource2 = new Resource('http://example.com/2', mockContext); 284 | mockContext.get.and.callFake(function (uri) { 285 | switch (uri) { 286 | case resource.$uri: return resource; 287 | case resource1.$uri: return resource1; 288 | case resource2.$uri: return resource2; 289 | } 290 | }); 291 | mockContext.httpGet.and.callFake(function (resource) { 292 | return $q.when(resource); 293 | }); 294 | 295 | resource.$links.step1 = {href: resource1.$uri}; 296 | resource1.step2 = resource2.$uri; 297 | 298 | var ts1 = new Date('2016-01-01').getTime(); 299 | var ts2 = new Date('2016-01-02').getTime(); 300 | resource.$syncTime = ts1; 301 | resource1.$syncTime = ts2; 302 | // resource2 is not synced 303 | 304 | resource.$loadPaths({step1: {}}, ts2).then(function () { 305 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 306 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource2); 307 | done(); 308 | }); 309 | $rootScope.$digest(); 310 | }); 311 | 312 | it('refreshes a resource if it has not been synced yet', function () { 313 | resource.$refresh(); 314 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 315 | }); 316 | 317 | it('refreshes a resource if it has been synced before', function () { 318 | resource.$syncTime = new Date('2016-01-01').getTime(); 319 | resource.$refresh(); 320 | expect(mockContext.httpGet).toHaveBeenCalled(); 321 | }); 322 | 323 | it('refreshes a resource if it has been synced but stale', function () { 324 | resource.$syncTime = new Date('2016-01-01').getTime(); 325 | resource.$refresh(new Date('2016-01-02').getTime()); 326 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 327 | }); 328 | 329 | it('does not refresh a resource if it has been synced before the refresh timestamp', function () { 330 | resource.$syncTime = new Date('2016-01-02').getTime(); 331 | resource.$refresh(new Date('2016-01-01').getTime()); 332 | expect(mockContext.httpGet).not.toHaveBeenCalledWith(resource); 333 | }); 334 | 335 | it('refreshes paths of related resources', function (done) { 336 | var resource1 = new Resource('http://example.com/1', mockContext); 337 | var resource2 = new Resource('http://example.com/2', mockContext); 338 | mockContext.get.and.callFake(function (uri) { 339 | switch (uri) { 340 | case resource.$uri: return resource; 341 | case resource1.$uri: return resource1; 342 | case resource2.$uri: return resource2; 343 | } 344 | }); 345 | mockContext.httpGet.and.callFake(function (resource) { 346 | return $q.when(resource); 347 | }); 348 | 349 | resource.$links.step1 = {href: resource1.$uri}; 350 | resource1.step2 = resource2.$uri; 351 | 352 | var ts = new Date('2016-01-01').getTime(); 353 | resource.$syncTime = ts; 354 | resource1.$syncTime = ts; 355 | // resource2 is not synced 356 | 357 | resource.$refreshPaths({step1: {}}).then(function () { 358 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 359 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource1); 360 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource2); 361 | done(); 362 | }); 363 | $rootScope.$digest(); 364 | }); 365 | 366 | it('generates info message if path not found', function (done) { 367 | spyOn($log, 'warn'); 368 | mockContext.get.and.callFake(function (uri) { 369 | switch (uri) { 370 | case resource.$uri: return resource; 371 | } 372 | }); 373 | mockContext.httpGet.and.callFake(function (resource) { 374 | return $q.when(resource); 375 | }); 376 | 377 | resource.$loadPaths({nonexistent: {}}).then(function () { 378 | expect($log.warn).toHaveBeenCalledWith('Warning while loading path "nonexistent" from resource "http://example.com": property or link "nonexistent" not found on resource "http://example.com"'); 379 | done(); 380 | }); 381 | $rootScope.$digest(); 382 | }); 383 | 384 | // HTTP 385 | 386 | it('creates HTTP GET request', function () { 387 | expect(resource.$getRequest()).toEqual({ 388 | method: 'get', 389 | url: 'http://example.com', 390 | headers: {Accept: 'application/json'} 391 | }); 392 | }); 393 | 394 | it('delegates HTTP GET request to the context', function () { 395 | resource.$get(); 396 | expect(mockContext.httpGet).toHaveBeenCalledWith(resource); 397 | }); 398 | 399 | it('creates HTTP PUT request', function () { 400 | expect(resource.$putRequest()).toEqual({ 401 | method: 'put', 402 | url: 'http://example.com', 403 | data: resource, 404 | headers: {'Content-Type': 'application/json'} 405 | }); 406 | }); 407 | 408 | it('delegates HTTP PUT request to the context', function () { 409 | resource.$put(); 410 | expect(mockContext.httpPut).toHaveBeenCalledWith(resource); 411 | }); 412 | 413 | it('creates HTTP PATCH request', function () { 414 | var data = {}; 415 | expect(resource.$patchRequest(data)).toEqual({ 416 | method: 'patch', 417 | url: 'http://example.com', 418 | data: data, 419 | headers: {'Content-Type': 'application/merge-patch+json'} 420 | }); 421 | }); 422 | 423 | it('delegates HTTP PATCH request to the context', function () { 424 | var data = {}; 425 | resource.$patch(data); 426 | expect(mockContext.httpPatch).toHaveBeenCalledWith(resource, data); 427 | }); 428 | 429 | it('creates HTTP DELETE request', function () { 430 | expect(resource.$deleteRequest()).toEqual({ 431 | method: 'delete', 432 | url: 'http://example.com' 433 | }); 434 | }); 435 | 436 | it('delegates HTTP DELETE request to the context', function () { 437 | resource.$delete(); 438 | expect(mockContext.httpDelete).toHaveBeenCalledWith(resource); 439 | }); 440 | 441 | it('creates HTTP POST request', function () { 442 | function callback(config) { 443 | return angular.extend(config, {extra: 'value'}); 444 | } 445 | 446 | expect(resource.$postRequest('data', {foo: 'bar'}, callback)).toEqual({ 447 | method: 'post', 448 | url: 'http://example.com', 449 | data: 'data', 450 | headers: {foo: 'bar'}, 451 | extra: 'value' 452 | }); 453 | }); 454 | 455 | it('delegates HTTP POST request to the context', function () { 456 | resource.$post('data', {foo: 'bar'}, angular.identity); 457 | expect(mockContext.httpPost).toHaveBeenCalledWith(resource, 'data', {foo: 'bar'}, angular.identity); 458 | }); 459 | 460 | // Updates 461 | 462 | it('updates state, links and profile', function () { 463 | resource.aVar = 'test'; 464 | resource.$update({foo: 'bar'}, {profile: {href: 'http://example.com/profile'}}); 465 | expect(resource.aVar).toBeUndefined(); 466 | expect(resource.foo).toBe('bar'); 467 | expect(resource.$links).toEqual({self: {href: uri}, profile: {href: 'http://example.com/profile'}}); 468 | expect(resource.$profile).toBe('http://example.com/profile'); 469 | }); 470 | 471 | it('does not remove properties starting with "$$" during update', function () { 472 | resource.prop = 'foo'; 473 | resource.$$prop = 'test'; 474 | resource.$update({prop: 'bar'}, {profile: {href: 'http://example.com/profile'}}); 475 | expect(resource.prop).toBe('bar'); 476 | expect(resource.$$prop).toBe('test'); 477 | }); 478 | 479 | it('updates special objects correctly', function () { 480 | var data = { 481 | blob: new Blob(['test']), 482 | arrayBuffer: new ArrayBuffer(1), 483 | document: document 484 | }; 485 | resource.$update(data); 486 | expect(resource.blob).toEqual(data.blob); 487 | expect(resource.arrayBuffer).toEqual(data.arrayBuffer); 488 | expect(resource.document).toEqual(data.document); 489 | }); 490 | 491 | it('requires the href of a link with the "self" relation to equal the resource URI if not enableAliases', function () { 492 | mockContext.enableAliases = false; 493 | resource.$update({foo: 'bar'}, {self: {href: 'http://example.com'}}); 494 | expect(function () { 495 | resource.$update({foo: 'qux'}, {self: {href: 'http://example.com/other'}}); 496 | }).toThrowError('Self link href differs: expected "http://example.com", was "http://example.com/other"'); 497 | }); 498 | 499 | it('adds a new self href to the context if enableAliases', function () { 500 | mockContext.enableAliases = true; 501 | resource.$update({foo: 'bar'}, {self: {href: 'http://example.com'}}); 502 | resource.$update({foo: 'qux'}, {self: {href: 'http://example.com/other'}}); 503 | expect(mockContext.addAlias).toHaveBeenCalledWith('http://example.com/other', 'http://example.com'); 504 | }); 505 | 506 | // Merges 507 | 508 | it('merges state', function () { 509 | resource.aVar = 'foo'; 510 | resource.bVar = 'joe'; 511 | resource.$merge({aVar: null, bVar: 'john', newVar: 'bar'}); 512 | 513 | expect(resource.aVar).toBeUndefined(); 514 | expect(resource.bVar).toBe('john'); 515 | expect(resource.newVar).toBe('bar'); 516 | }); 517 | 518 | it('merges nested state', function () { 519 | resource.nested = { 520 | aVar: 'foo', 521 | bVar: 'joe' 522 | }; 523 | resource.$merge({ 524 | nested: { 525 | aVar: null, 526 | bVar: 'john', 527 | newVar: 'bar' 528 | }, 529 | newNested: { 530 | newVar: 'qux' 531 | } 532 | }); 533 | 534 | expect(resource.nested.aVar).toBeUndefined(); 535 | expect(resource.nested.bVar).toBe('john'); 536 | expect(resource.nested.newVar).toBe('bar'); 537 | expect(resource.newNested.newVar).toBe('qux'); 538 | }); 539 | }); 540 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('hypermedia') 4 | 5 | /** 6 | * @ngdoc object 7 | * @name HypermediaUtil 8 | * @description 9 | * 10 | * Utility functions used in the hypermedia module. 11 | */ 12 | .factory('HypermediaUtil', function () { 13 | return { 14 | 15 | /** 16 | * Call a function on an argument or every element of an array. 17 | * 18 | * @param {Array|*|undefined} arg the variable or array of variables to apply 'func' to 19 | * @param {function} func the function 20 | * @param {object} [context] object to bind 'this' to when applying 'func' 21 | * @returns {Array|*|undefined} the result of applying 'func' to 'arg'; undefined if 'arg' is undefined 22 | */ 23 | forArray: function forArray(arg, func, context) { 24 | if (angular.isUndefined(arg)) return undefined; 25 | if (Array.isArray(arg)) { 26 | return arg.map(function (elem) { 27 | return func.call(context, elem); 28 | }); 29 | } else { 30 | return func.call(context, arg); 31 | } 32 | }, 33 | 34 | /** 35 | * Call Object.defineProperties but configure all properties as writable. 36 | */ 37 | defineProperties: function defineProperties(obj, props) { 38 | props = angular.copy(props); 39 | angular.forEach(props, function (prop) { 40 | if (!('writable' in prop)) prop.writable = true; 41 | }); 42 | Object.defineProperties(obj, props); 43 | } 44 | }; 45 | }) 46 | 47 | ; 48 | -------------------------------------------------------------------------------- /src/util.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('HypermediaUtil', function () { 4 | beforeEach(module('hypermedia')); 5 | 6 | // Setup 7 | 8 | var HypermediaUtil; 9 | 10 | beforeEach(inject(function (_HypermediaUtil_) { 11 | HypermediaUtil = _HypermediaUtil_; 12 | })); 13 | 14 | 15 | describe('forArray', function () { 16 | 17 | // Setup 18 | 19 | var forArray; 20 | 21 | beforeEach(function () { 22 | forArray = HypermediaUtil.forArray; 23 | }); 24 | 25 | // Tests 26 | 27 | it('silently ignores undefined', function () { 28 | var result = forArray(undefined, function (s) { 29 | return s.toUpperCase(); 30 | }); 31 | expect(result).toBeUndefined(); 32 | }); 33 | 34 | it('calls a function with a scalar', function () { 35 | var result = forArray('a', function (s) { 36 | return s.toUpperCase(); 37 | }); 38 | expect(result).toBe('A'); 39 | }); 40 | 41 | it('calls a function with an array', function () { 42 | var result = forArray(['a', 'b'], function (s) { 43 | return s.toUpperCase(); 44 | }); 45 | expect(result).toEqual(['A', 'B']); 46 | }); 47 | 48 | it('descends only one level', function () { 49 | var result = forArray([['a', 'b'], ['c', 'd']], function (s) { 50 | return s.reverse(); 51 | }); 52 | expect(result).toEqual([['b', 'a'], ['d', 'c']]); 53 | }); 54 | }); 55 | 56 | describe('defineProperties', function () { 57 | // Setup 58 | 59 | var defineProperties; 60 | 61 | beforeEach(function () { 62 | defineProperties = HypermediaUtil.defineProperties; 63 | }); 64 | 65 | // Tests 66 | 67 | it('should have added all properties', function () { 68 | var obj = {}; 69 | 70 | var props = { 71 | "property1": { 72 | value: true 73 | }, 74 | "property2": { 75 | value: "Hello" 76 | }, 77 | "property3": { 78 | value: function () {} 79 | } 80 | }; 81 | 82 | defineProperties(obj, props); 83 | 84 | expect(obj.property1).toBeTruthy(); 85 | expect(obj.property2).toBeTruthy(); 86 | expect(obj.property3).toBeTruthy(); 87 | 88 | }); 89 | 90 | it('should add writable=true to all properties', function () { 91 | var obj = {}; 92 | 93 | var props = { 94 | "property1": { 95 | value: true 96 | }, 97 | "property2": { 98 | value: "Hello" 99 | } 100 | }; 101 | 102 | defineProperties(obj, props); 103 | 104 | // test if properties are writable 105 | obj.property1 = false; 106 | obj.property2 = "World"; 107 | 108 | expect(obj.property1).toEqual(false); 109 | expect(obj.property2).toEqual("World"); 110 | 111 | }); 112 | 113 | it('should skip properties that already have a writable attribute', function () { 114 | var obj = {}; 115 | 116 | var props = { 117 | "property1": { 118 | value: "mutable" 119 | }, 120 | "property2": { 121 | value: "read-only", 122 | writable: false 123 | } 124 | }; 125 | 126 | defineProperties(obj, props); 127 | 128 | // test if properties are writable 129 | obj.property1 = "mutated"; 130 | 131 | expect(function () { 132 | obj.property2 = "written"; 133 | }).toThrow(new TypeError("Attempted to assign to readonly property.")); 134 | 135 | expect(obj.property1).toEqual("mutated"); 136 | expect(obj.property2).toEqual("read-only"); 137 | 138 | }); 139 | 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/vnderror.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('hypermedia') 4 | 5 | .run(['$q', 'ResourceContext', 'VndError',function ($q, ResourceContext, VndError) { 6 | var vndErrorHandler = function (response) { 7 | return new VndError(response.data); 8 | }; 9 | 10 | ResourceContext.registerErrorHandler('application/vnd.error+json', vndErrorHandler); 11 | }]) 12 | 13 | /** 14 | * @ngdoc type 15 | * @name VndError 16 | * @description 17 | * 18 | * VndError represents errors from server with content type 'application/vnd+error', 19 | * see: https://github.com/blongden/vnd.error 20 | */ 21 | .factory('VndError', function () { 22 | var VndError = function (data) { 23 | this.message = data.message; 24 | this.logref = data.logref; 25 | this.path = data.path; 26 | this.$links = data._links || []; 27 | 28 | this.$nested = []; 29 | var embeds = data._embedded && data._embedded.errors; 30 | if (embeds) { 31 | if (!Array.isArray(embeds)) { 32 | embeds = [embeds]; 33 | } 34 | embeds.forEach(function (embed) { 35 | this.$nested.push(new VndError(embed)); 36 | }, this); 37 | } 38 | }; 39 | 40 | return VndError; 41 | }) 42 | 43 | 44 | ; 45 | -------------------------------------------------------------------------------- /src/vnderror.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('HalError', function () { 4 | beforeEach(module('hypermedia')); 5 | 6 | var VndError; 7 | 8 | beforeEach(inject(function (_VndError_) { 9 | VndError = _VndError_; 10 | })); 11 | 12 | it('can be constructed with embedded errors', function () { 13 | var data = { 14 | 'message': 'Validation failed', 15 | 'logref': 42, 16 | 'path': '/username', 17 | '_links': {'profile': {'href': 'http://nocarrier.co.uk/profiles/vnd.error/'}}, 18 | '_embedded': { 19 | 'errors': { 20 | 'message': 'Invalid number', 21 | '_links': {'profile': {'href': 'http://nocarrier.co.uk/profiles/vnd.error/'}} 22 | } 23 | } 24 | }; 25 | 26 | var error = new VndError(data); 27 | 28 | expect(error.message).toBe('Validation failed'); 29 | expect(error.logref).toBe(42); 30 | expect(error.path).toBe('/username'); 31 | expect(error.$links.profile).toEqual({'href': 'http://nocarrier.co.uk/profiles/vnd.error/'}); 32 | expect(error.$nested[0].message).toBe('Invalid number'); 33 | }); 34 | 35 | it('can be constructed without embedded errors', function () { 36 | var data = { 37 | 'message': 'Validation failed', 38 | '_links': {'profile': {'href': 'http://nocarrier.co.uk/profiles/vnd.error/'}} 39 | }; 40 | 41 | var error = new VndError(data); 42 | 43 | expect(error.$nested.length).toBe(0); 44 | }); 45 | }); 46 | --------------------------------------------------------------------------------