├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGELOG.md ├── DETAILED_GUIDE.md ├── LICENSE ├── README.md ├── demo ├── README.md ├── pubspec.yaml ├── server.dart └── web │ ├── app.dart │ ├── main.html │ ├── partials │ ├── app.html │ ├── post.html │ └── site.html │ └── util │ └── mirror_based_serializers.dart ├── karma.conf.js ├── lib ├── hammock.dart ├── hammock_core.dart └── src │ ├── config.dart │ ├── custom_request_params.dart │ ├── object_store.dart │ ├── request_defaults.dart │ ├── resource_store.dart │ └── utils.dart ├── package.json ├── pubspec.yaml ├── scripts └── travis │ └── run_tests.sh └── test ├── angular_guinness.dart ├── hammock_test.dart └── src ├── config_test.dart ├── integration_test.dart ├── object_store_test.dart └── resource_store_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | packages 3 | TODO.txt 4 | node_modules 5 | pubspec.lock 6 | notes -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | before_install: 5 | - export DISPLAY=:99.0 6 | script: 7 | - ./scripts/travis/run_tests.sh -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Victor Savkin 2 | Victor Berchet -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.2.4 - (2014-09-07) 2 | 3 | * Add support for request defaults. 4 | 5 | # v0.2.3 - (2014-07-16) 6 | 7 | * Extract all the code that can be used both on the client and server into `hammock_core`. 8 | 9 | # v0.2.2 (2014-07-04) 10 | 11 | * Add support for async deserializers. 12 | * Add support for injectable serializers and deserializers. 13 | 14 | # v0.2.1 (2014-07-03) 15 | 16 | * Add support for custom queries and commands. 17 | * Add optional `params` to `queryList`. 18 | 19 | # v0.2.0 (2014-06-26) 20 | 21 | * Rename `save` into `update`. 22 | * Separate commands and queries, so you can configure different deserialization strategies for them. 23 | 24 | # v0.1.0 25 | 26 | Initial version -------------------------------------------------------------------------------- /DETAILED_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Hammock 2 | 3 | AngularDart service for working with Rest APIs 4 | 5 | 6 | 7 | ## Introduction to Hammock 8 | 9 | You can find a quick start guide [here](https://github.com/vsavkin/hammock). Look at it first before reading this guide. 10 | 11 | 12 | 13 | ## Resources 14 | 15 | Though most of the time you are going to work with `ObjectStore`, sometimes it is valuable to go lower-level and work with resources directly. 16 | 17 | 18 | You can create a resource like this: 19 | 20 | resource("posts", 1, {"title": "some post"}); 21 | 22 | Resource has a type, an id, and content. 23 | 24 | 25 | 26 | 27 | ## Using ResourceStore 28 | 29 | 30 | 31 | ### One 32 | 33 | The `one` method, which takes a resource type and an id, loads a resource. 34 | 35 | ```dart 36 | Future r = store.one("posts", 123); // GET "/posts/123" 37 | ``` 38 | 39 | 40 | 41 | ### List 42 | 43 | The `list` method, which takes a resource type, loads all the resources of the given type. 44 | 45 | ```dart 46 | Future> rs = store.list("posts"); // GET "/posts" 47 | Future> rs = store.list("posts", params: {"createdAfter": '2014'}); // GET "/posts?createdAfter=2014" 48 | ``` 49 | 50 | Actually, the `list` method returns an instance of `QueryResult`. It is a list with an extra property: `meta`, which can be used by the backend to pass extra information to the client (e.g., pagination). 51 | 52 | 53 | 54 | 55 | ### Nested Resources 56 | 57 | The `scope` method, which takes a resource, allows fetching nested resources. 58 | 59 | ```dart 60 | final post = resource("posts", 123); 61 | Future r = store.scope(post).one("comments", 456); // GET "/posts/123/comments/456" 62 | Future> rs = store.scope(post).list("comments"); // GET "/posts/123/comments" 63 | ``` 64 | 65 | `scope` returns a new store: 66 | 67 | ```dart 68 | ResourceStore scopeStore = store.scope(post); 69 | ``` 70 | 71 | You can scope an already scoped store: 72 | 73 | ```dart 74 | store.scope(blog).scope(post); 75 | ``` 76 | 77 | 78 | 79 | ### Create 80 | 81 | To create a resource call the `create` method: 82 | 83 | ```dart 84 | final post = resource("posts", null, {"title": "New"}); 85 | store.create(post); // POST "/posts" 86 | ``` 87 | 88 | 89 | 90 | ### Update 91 | 92 | Use `update` to change the existing resource: 93 | 94 | ```dart 95 | final post = resource("posts", 123, {"id": 123, "title": "New"}); 96 | store.update(post); // PUT "/posts/123" 97 | ``` 98 | 99 | 100 | 101 | ### Delete 102 | 103 | Use `delete` to delete the existing resource: 104 | 105 | ```dart 106 | final post = resource("posts", 123, {"id": 123, "title": "New"}); 107 | store.delete(post); // DELETE "/posts/123" 108 | ``` 109 | 110 | Use `scope` to create, update, and delete nested resources. 111 | 112 | 113 | 114 | ### CommandResponse 115 | 116 | All the commands return a `CommandResponse`. For instance: 117 | 118 | ```dart 119 | Future createdPost = store.create(post); 120 | ``` 121 | 122 | 123 | 124 | ### Custom Queries 125 | 126 | 127 | Use `customQueryOne` and `customQueryList` to make custom queries: 128 | 129 | ``dart 130 | Future r = store.customQueryOne("posts", new CustomRequestParams(method: "GET", url:"/posts/123")); 131 | Future> rs = store.customQueryList("posts", new CustomRequestParams(method: "GET", url:"/posts")); 132 | `` 133 | 134 | 135 | 136 | ### Custom Commands 137 | 138 | And `customCommand` to execute custom commands: 139 | 140 | ``dart 141 | final post = resource("posts", 123); 142 | store.customCommand(post, new CustomRequestParams(method: 'DELETE', url: '/posts/123')); 143 | `` 144 | 145 | Using custom queries and command is discouraged. 146 | 147 | 148 | 149 | 150 | ## Configuring ResourceStore 151 | 152 | `HammockConfig` allows you to configure some aspects of `ResourceStore`. 153 | 154 | 155 | 156 | ### Setting Up Route 157 | 158 | ```dart 159 | config.set({"posts" : {"route" : "custom"}}); 160 | ``` 161 | 162 | `ResourceStore` will use "custom" to build the url when fetching/saving posts. For instance: 163 | 164 | ```dart 165 | store.one("posts", 123) // GET "/custom/123" 166 | ``` 167 | 168 | 169 | 170 | ### Setting Up UrlRewriter 171 | 172 | ```dart 173 | config.urlRewriter.baseUrl = "/base"; 174 | config.urlRewriter.suffix = ".json"; 175 | 176 | store.one("posts", 123); // GET "/base/posts/123.json" 177 | ``` 178 | 179 | Or even like this: 180 | 181 | ```dart 182 | config.urlRewriter = (url) => "$url.custom"; 183 | 184 | store.one("posts", 123); // GET "/posts/123.custom" 185 | ``` 186 | 187 | ### Setting Up Request Defaults 188 | 189 | ```dart 190 | config.requestDefaults.params = {"token", "secret"}; 191 | store.one("posts", 123); // GET "/posts/123?token=secret" 192 | ``` 193 | 194 | Custom queries and commands do not use request defaults. 195 | 196 | ### DocumentFormat 197 | 198 | `DocumentFormat` defines how resources are serialized into documents. `SimpleDocumentFormat` is used by default. It can be overwritten as follows: 199 | 200 | config.documentFormat = new CustomDocumentFormat(); 201 | 202 | Please see `integration_test.dart` for more details. 203 | 204 | 205 | 206 | 207 | ## Configuring the Http Service 208 | 209 | Hammock is built on top of the `Http` service provided by Angular. Consequently, you can configure Hammock by configuring `Http`. 210 | 211 | ```dart 212 | final headers = injector.get(HttpDefaultHeaders); 213 | headers.setHeaders({'Content-Type' : 'custom-type'}, 'GET'); 214 | ``` 215 | 216 | 217 | 218 | 219 | ## Using ObjectStore 220 | 221 | `ObjectStore` is responsible for: 222 | 223 | * Converting object into resources 224 | * Converting resources into objects 225 | * Updating objects 226 | * Using `ResourceStore` to send objects to the server 227 | 228 | Suppose we have these classed defined: 229 | 230 | ```dart 231 | class Post { 232 | int id; 233 | String title; 234 | dynamic errors; 235 | Post(this.id, this.title); 236 | } 237 | 238 | class Comment { 239 | int id; 240 | String text; 241 | Comment(this.id, this.text); 242 | } 243 | ``` 244 | 245 | Plus this configuration: 246 | 247 | 248 | ```dart 249 | config.set({ 250 | "posts" : { 251 | "type" : Post, 252 | "deserializer" : deserializePost, 253 | "serializer" : serializePost 254 | }, 255 | "comments" : { 256 | "type": Comment, 257 | "deserializer" : deserializeComment, 258 | "serializer" : serializeComment 259 | } 260 | }); 261 | ``` 262 | 263 | Where the serialization and deserialization functions are responsible for converting domain objects from/into resources. 264 | 265 | ```dart 266 | Post deserializePost(Resource r) => new Post(id, r.content["title")); 267 | Resource serializePost(Post post) => resource("posts", post.id, {"id" : post.id, "title" : post.title}); 268 | Comment deserializeComment(Resource r) => new Comment(id, content["text"]); 269 | Resource serializeComment(Comment comment) => resource("comments", comment.id, {"id" : comment.id, "text" : comment.text}); 270 | ``` 271 | 272 | 273 | 274 | ### One 275 | 276 | The `one` method, which takes a type and an id, loads an object. 277 | 278 | ```dart 279 | Future p = store.one(Post, 123); // GET "/posts/123" 280 | Future c = store.scope(post).one(Comment, 456); //GET "posts/123/comments/456" 281 | ``` 282 | 283 | 284 | 285 | ### List 286 | 287 | The `list` method, which takes a type, loads all the objects of the given type. 288 | 289 | ```dart 290 | Future> ps = store.list(Post); // GET "/posts" 291 | Future> ps = store.list(Post, params: {"createdAfter": "2014"}); // GET "/posts?createdAfter=2014" 292 | Future> cs = store.scope(post).list(Comment); //GET "/posts/123/comments" 293 | ``` 294 | 295 | As you can see it is very similar to `ResourceStore`, but we can use our domain objects instead of `Resource`. 296 | 297 | 298 | 299 | ### Commands 300 | 301 | With the current configuration `create`, `update`, `delete`, `customCommand` return a new object. For example: 302 | 303 | ```dart 304 | final post = new Post(123, "title"); 305 | Future p = store.update(post); // PUT '/posts/123' 306 | ``` 307 | 308 | Let's say the backend returns the updated post object, for instance, serialized like this `{"id":123,"title":"New"}`. 309 | 310 | ```dart 311 | final post = new Post(123, "title"); 312 | store.update(post).then((updatedPost) { 313 | expect(updatedPost.title).toEqual("New"); 314 | expect(post.title).toEqual("title"); 315 | }); 316 | ``` 317 | 318 | `post` was not updated, and instead a new post object was created. This is great cause it allows you to keep you objects immutable. Sometimes, however, we would like to treat our objects as entities and update them instead. To do that, we need configure our store differently: 319 | 320 | ```dart 321 | config.set({ 322 | "posts" : { 323 | "type" : Post, 324 | "serializer" : serializePost, 325 | "deserializer" : { 326 | "query" : deserializePost, 327 | "command" : updatePost 328 | } 329 | } 330 | }); 331 | ``` 332 | 333 | Where `updatePost`: 334 | 335 | ```dart 336 | updatePost(Post post, CommandResponse r) { 337 | post.title = r.content["title"]; 338 | return post; 339 | } 340 | ``` 341 | 342 | In this case: 343 | 344 | ```dart 345 | final post = new Post(123, "title"); 346 | store.update(post).then((updatedPost) { 347 | expect(updatedPost.title).toEqual("New"); 348 | expect(post.title).toEqual("New"); 349 | expect(post).toBe(updatedPost); 350 | }); 351 | ``` 352 | 353 | Finally, let's configure our store to handle errors differently: 354 | 355 | ```dart 356 | config.set({ 357 | "posts" : { 358 | "type" : Post, 359 | "serializer" : serializePost, 360 | "deserializer" : { 361 | "query" : deserializePost, 362 | "command" : { 363 | "success" : updatePost, 364 | "error" : parseErrors 365 | } 366 | } 367 | } 368 | }); 369 | ``` 370 | 371 | Where `parseErrors`: 372 | 373 | ```dart 374 | parseErrors(Post post, CommandResponse r) { 375 | return r.content["errors"]; 376 | } 377 | ``` 378 | 379 | Now, if the backend returns `{"errors" : {"title": ["some error"]}}`. 380 | 381 | ```dart 382 | final post = new Post(123, "title"); 383 | store.update(post).catchError((errs) { 384 | expect(errs["title"]).toEqual(["some error"]); 385 | }); 386 | ``` 387 | 388 | ### Custom Queries and Commands 389 | 390 | Similar to `ResourceStore`, `ObjectStore` supports custom queries and commands. 391 | 392 | 393 | 394 | 395 | ## Async Deserializers 396 | 397 | Hammock support deserializers returning `Future`s, which can be useful for a variety of things: 398 | 399 | * You can fetch some extra information while deserializing an object. 400 | * You can implement error handling in your success deserializer. Just return a `Future.error`. 401 | 402 | 403 | ```dart 404 | class DeserializePost { 405 | ObjectStore store; 406 | DeserializePost(this.store); 407 | 408 | call(Resource r) { 409 | final post = new Post(r.id, r.content["title"]); 410 | return store.scope(post).list(Comment).then((comments) { 411 | post.comments = comments; 412 | return post; 413 | }); 414 | } 415 | } 416 | ``` 417 | 418 | 419 | ## Injectable Serializers and Deserializers 420 | 421 | If you pass a type as a serializer or a deserializer, Hammock will use `Injector` to get an instance of that type. 422 | 423 | ```dart 424 | config.set({ 425 | "posts" : { 426 | "type" : Post, 427 | "deserializer" : DeserializePost 428 | } 429 | } 430 | }); 431 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014 the Hammock library authors 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hammock 2 | 3 | AngularDart service for working with Rest APIs 4 | 5 | [![Build Status](https://travis-ci.org/vsavkin/hammock.svg?branch=master)](https://travis-ci.org/vsavkin/hammock) 6 | 7 | 8 | 9 | ## Installation 10 | 11 | You can find the Hammock installation instructions [here](http://pub.dartlang.org/packages/hammock#installing). 12 | 13 | After you have installed the dependency, you need to install the Hammock module: 14 | 15 | ```dart 16 | module.install(new Hammock()); 17 | ``` 18 | 19 | This makes the following services injectable: 20 | 21 | * `ResourceStore` 22 | * `ObjectStore` 23 | * `HammockConfig` 24 | 25 | 26 | ## Hammock.Mapper 27 | 28 | Check out [Hammock.Mapper](https://github.com/vsavkin/hammock_mapper). It is an easy to use library that uses conventions and a bit of meta information to generate all the necessary Hammock configuration. 29 | 30 | 31 | ## Overview 32 | 33 | This is how Hammock works: 34 | 35 | ![Overview](https://31.media.tumblr.com/34f3f94ac5b23a0c214ee63c129848b9/tumblr_n8atj11xtE1qc0howo2_500.png) 36 | 37 | ### Objects 38 | 39 | `ObjectStore` converts domain objects into resources and sends them over the wire. It uses serialization and deserialization functions to do that. It is built on top of `ResourceStore`. 40 | 41 | ### Resources 42 | 43 | `Resource` is an addressable entity that has a type, an id, and content. `Resource` is data, and it is immutable. `ResourceStore` sends resources over the wire. 44 | 45 | ### Documents 46 | 47 | Document is what you send and receive from the server, and it is a String. It can include one or many resources. `DocumentFormat` specifies how to convert resources into documents and vice versa. By default, Hammock uses a very simple json-based document format, but you can provide your own, and it does not even have to be json-based. 48 | 49 | 50 | Though at some point you may have to provision a new document format or deal with resources directly, most of the time, you will use `ObjectStore`. That's why I will mostly talk about configuring and using `ObjectStore`. 51 | 52 | 53 | 54 | ## Queries and Commands 55 | 56 | There are two types of operations in Hammock: queries and commands. 57 | 58 | Queries: 59 | 60 | ```dart 61 | Future one(type, id); 62 | Future list(type, {Map params}); 63 | Future customQueryOne(type, CustomRequestParams params); 64 | Future customQueryList(type, CustomRequestParams params); 65 | ``` 66 | 67 | Commands: 68 | 69 | ```dart 70 | Future create(object); 71 | Future update(object); 72 | Future delete(object); 73 | Future customCommand(object, CustomRequestParams params); 74 | ``` 75 | 76 | ## Queries 77 | 78 | ![Queries](https://31.media.tumblr.com/a5623c9e88a2180358e3eae6e1dc51e1/tumblr_n8atj11xtE1qc0howo1_1280.png) 79 | 80 | 81 | Hammock supports four types of queries: `one`, `list`, `customQueryOne`, and `customQueryList`. All of them return either an object or a list of objects. You can think about queries as retrieving objects from a collection. 82 | 83 | Let's say we have the following model defined: 84 | 85 | ```dart 86 | class Post { 87 | int id; 88 | String title; 89 | Post(this.id, this.title); 90 | } 91 | ``` 92 | 93 | And we want to use Hammock to fetch some posts from the backend. The first thing we need to do is provide this configuration: 94 | 95 | ```dart 96 | config.set({ 97 | "posts" : { 98 | "type" : Post, 99 | "deserializer": {"query" : deserializePost} 100 | } 101 | }) 102 | ``` 103 | 104 | Where `deserializePost` is defined as follows: 105 | 106 | ```dart 107 | deserializePost(Resource r) => new Post(r.id, r.content["title"]); 108 | ``` 109 | 110 | This configuration tells Hammock that we have the resource type "posts", which is mapped to the class `Post`, and when querying we should use `deserializePost` to convert resources into `Post` objects. Pretty straightforward. 111 | 112 | Let's try some queries: 113 | 114 | ```dart 115 | Future p = store.one(Post, 123); // GET /posts/123 116 | Future> ps = store.list(Post); // GET /posts 117 | Future> ps = store.list(Post, params: {"createdAfter": "2014"}); // GET /posts?createdAfter=2014 118 | ``` 119 | 120 | 121 | 122 | ## Commands 123 | 124 | ![Commands](https://31.media.tumblr.com/e7c5c9af9804eae0ec3af8ff72d3f93a/tumblr_n8atj11xtE1qc0howo3_1280.png) 125 | 126 | Hammock has four types of commands: `create`, `update`, `delete`, and `customCommand`. 127 | 128 | Let's start with something very simple - deleting a post. 129 | 130 | Having the following configuration: 131 | 132 | ```dart 133 | config.set({ 134 | "posts" : { 135 | "type" : Post 136 | } 137 | }); 138 | ``` 139 | 140 | we can delete a post: 141 | 142 | Future c = store.delete(post); // DELETE /posts/123 143 | 144 | ### Defining Serializers 145 | 146 | Now, something a bit more complicated. Let's create a new post. 147 | 148 | store.create(new Post(null, "some title")); // POST /posts 149 | 150 | If we execute this command, we will see the following error message: `No serializer for posts`. This makes sense if you think about it. The creation of a new resource involves submitting a document with that resource. 151 | 152 | To fix this problem we need to define a serializer. 153 | 154 | ```dart 155 | config.set({ 156 | "posts" : { 157 | "type" : Post, 158 | "serializer" : serializePost 159 | } 160 | }); 161 | 162 | Resource serializePost(Post post) => 163 | resource("posts", post.id, {"id" : post.id, "title" : post.title}); 164 | ``` 165 | 166 | The error message is gone, and the resource has been successfully created. There is an issue however; we do not know the id of the created post. 167 | 168 | To fix it we need to look at the response that we got after submitting our post. Let's say it looked something like this: 169 | 170 | ```dart 171 | {"id" : 8989, "title" : "some title"} 172 | ``` 173 | 174 | ### Defining Deserializers 175 | 176 | How do we use this response to update our `Post` object? We need to define a special deserializer. 177 | 178 | ```dart 179 | config.set({ 180 | "posts" : { 181 | "type" : Post, 182 | "serializer" : serializePost, 183 | "deserializer" : {"command" : updatePost} 184 | } 185 | }); 186 | 187 | Post updatePost(Post post, CommandResponse resp) { 188 | post.id = resp.content["id"]; 189 | return post; 190 | } 191 | ``` 192 | 193 | As you have probably noticed, command deserializers are slightly different from query deserializers. Whereas query deserializers always create a new object, command deserializers are more generic, and can, for instance, update an existing object. 194 | 195 | Having all this in place, we have finally gotten the behaviour we wanted: 196 | 197 | ```dart 198 | final post = new Post(null, "some title"); 199 | store.create(post).then((_) { 200 | //post.id == 8989; when the callback is called, the id field has been already set. 201 | }); 202 | ``` 203 | 204 | ### FP 205 | 206 | If you are a fan of functional programming, you do not want to have all these side effects in your deserializer. Instead, you want to create a new `Post` object with the id field set. Hammock supports this use case: 207 | 208 | ```dart 209 | Post updatePost(Post post, CommandResponse resp) => 210 | new Post(resp.content["id"], resp.content["title"]); 211 | ``` 212 | 213 | And since it is so common, you can use query deserializers for this purpose. 214 | 215 | ```dart 216 | config.set({ 217 | "posts" : { 218 | "type" : Post, 219 | "serializer" : serializePost, 220 | "deserializer" : {"command" : deserializePost} 221 | } 222 | }); 223 | 224 | deserializePost(Resource r) => new Post(r.id, r.content["title"]); 225 | ``` 226 | 227 | ### Error Handling 228 | 229 | Let's say we are trying to save a post with a blank title. 230 | 231 | ```dart 232 | store.create(new Post(null, "")); 233 | ``` 234 | 235 | This server does not like it and responds with an error. 236 | 237 | ```dart 238 | {"errors" : {"title" : ["cannot be blank"]}} 239 | ``` 240 | 241 | How can we handle this error? 242 | 243 | The first approach is to modify `updatePost`, as follows: 244 | 245 | ```dart 246 | Post updatePost(Post post, CommandResponse resp) { 247 | if (resp.content["errors"] != null) throw resp.content["errors"]; 248 | return new Post(resp.content["id"], resp.content["title"]); 249 | } 250 | ``` 251 | 252 | After that: 253 | 254 | ```dart 255 | store.create(new Post(null, "")).catchError((errors) => showErrors(errors)); 256 | ``` 257 | 258 | The downside is that we have to do this check in all your deserializers. This is not DRY. What we can do instead is to define a special deserializer for errors. 259 | 260 | ```dart 261 | parseErrors(obj, CommandResponse resp) => resp.content["errors"]; 262 | 263 | config.set({ 264 | "posts" : { 265 | "type" : Post, 266 | "serializer" : serializePost, 267 | "deserializer" : 268 | {"command" : { 269 | "success" : deserializePost, 270 | "error" : parseErrors} 271 | } 272 | } 273 | }); 274 | ``` 275 | 276 | It achieves the same affect but keeps error handling separate. 277 | 278 | Finally, if we choose to store errors on the domain object itself, it is easily configurable. 279 | 280 | ```dart 281 | class Post { 282 | int id; 283 | String title; 284 | Map errors = {}; 285 | Post(this.id, this.title); 286 | } 287 | parseErrors(obj, CommandResponse resp) { 288 | obj.errors = resp.content["errors"]; 289 | return obj; 290 | } 291 | ``` 292 | 293 | 294 | 295 | ## Nested Resources 296 | 297 | Hammock supports nested resources. 298 | 299 | ```dart 300 | class Comment { 301 | int id; 302 | String text; 303 | Comment(this.id, this.text); 304 | } 305 | store.scope(post).list(Comment); // GET /posts/123/comments 306 | store.scope(post).update(comment); // POUT /posts/123/comments/456 307 | ``` 308 | 309 | 310 | ## Async Deserializers and Handling Associations 311 | 312 | Hammock does not have the notion of an association. But since the library is flexible enough, we can implement it ourselves. 313 | 314 | Let's add comments to `Post`. 315 | 316 | ```dart 317 | class Post { 318 | int id; 319 | String title; 320 | List comments = []; 321 | Post(this.id, this.title); 322 | } 323 | ``` 324 | 325 | And change our deserializer to fetch all the comments of the given post: 326 | 327 | ```dart 328 | class DeserializePost { 329 | ObjectStore store; 330 | DeserializePost(this.store); 331 | 332 | call(Resource r) { 333 | final post = new Post(r.id, r.content["title"]); 334 | return store.scope(post).list(Comment).then((comments) { 335 | post.comments = comments; 336 | return post; 337 | }); 338 | } 339 | } 340 | 341 | config.set({ 342 | "posts" : { 343 | "type" : Post, 344 | "serializer" : serializePost, 345 | "deserializer" : DeserializePost 346 | }, 347 | "comments" : { 348 | "type" : Comment, 349 | "deserializer" : deserializeComment 350 | } 351 | }); 352 | ``` 353 | 354 | There are a few interesting things shown here. First, Hammock supports async deserializers, which, as you can see, is very handy for loading additional resources during deserialization. Second, when given a type, Hammock will use `Injector` to get an instance of that type. This allows us to pass `ObjectStore` into our deserializer. 355 | 356 | Now, having all of this defined, we can run: 357 | 358 | ```dart 359 | store.one(Post, 123).then((post) { 360 | //post.comments are present 361 | }); 362 | ``` 363 | 364 | 365 | 366 | ## No Active Record 367 | 368 | Angular is different from other client-side frameworks. It lets us use simple framework-agnostic objects for our components, controllers, formatters, etc. Making users inherit from some class is against the Angular spirit. This is especially true when talking about domain objects. They should not have to know anything about Angular or the backend. Any object, including a simple 'Map', should be possible to load and save, if we wish so. That's why Hammock does not use the active record pattern. The library makes NO assumptions about the objects it works with. This is good news for FP and DDD fans. 369 | 370 | 371 | 372 | 373 | ## Detailed Guide 374 | 375 | You can find a more detailed guide to Hammock [here](https://github.com/vsavkin/hammock/blob/master/DETAILED_GUIDE.md). 376 | 377 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Trying Demo App 2 | 3 | 1. `cd demo`. 4 | 1. Run `dart server.dart`. 5 | 2. Open `localhost:3001/main.html`. 6 | 3. Open the network tab and check all the requests that have been made. 7 | 4. Remove everything from one of the site name fields and click on `Update Name`. You should see an error in the console. 8 | 6. Type in something and click on `Update Name` again. You should see `success`. 9 | 7. Refresh the page to make sure that the data is saved. 10 | 8. Delete one of the posts and refresh the page. 11 | 12 | The server script will print all the requests and responses. -------------------------------------------------------------------------------- /demo/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: hammock_demo_app 2 | author: Victor Savkin 3 | homepage: https://github.com/vsavkin/hammock 4 | dependencies: 5 | angular: '>=0.12.0 <1.0.0' 6 | http_server: any 7 | hammock: 8 | path: '../' 9 | environment: 10 | sdk: ">=1.4.0 <2.0.0" -------------------------------------------------------------------------------- /demo/server.dart: -------------------------------------------------------------------------------- 1 | library server; 2 | 3 | import 'dart:io'; 4 | import 'dart:async'; 5 | import 'dart:convert'; 6 | import 'package:http_server/http_server.dart' show VirtualDirectory; 7 | 8 | final postsReg = new RegExp(r'/api/sites/(\d+)/posts$'); 9 | final siteReg = new RegExp(r'/api/sites/(\d+)$'); 10 | final postReg = new RegExp(r'/api/sites/(\d+)/posts/(\d+)$'); 11 | 12 | var sites = [ 13 | {"id" : 1, "name" : "Site1"}, 14 | {"id" : 2, "name" : "Site2"} 15 | ]; 16 | 17 | var posts = [ 18 | {"siteId" : 1, "id" : 10, "title" : "Post1", "views" : 10}, 19 | {"siteId" : 1, "id" : 20, "title" : "Post2", "views" : 20}, 20 | {"siteId" : 2, "id" : 30, "title" : "Post3", "views" : 30} 21 | ]; 22 | 23 | handleRequest(HttpRequest request) { 24 | final p = request.uri.path; 25 | 26 | print("PROCESSING REQUEST: ${request.method} $p"); 27 | 28 | respond(obj, [status=200]){ 29 | print("RESPONSE: $status $obj"); 30 | request.response.statusCode = status; 31 | request.response.write(new JsonEncoder().convert(obj)); 32 | request.response.close(); 33 | } 34 | decode(str) => new JsonDecoder().convert(str); 35 | siteId(regExp) => int.parse(regExp.firstMatch(p).group(1)); 36 | postId(regExp) => int.parse(regExp.firstMatch(p).group(2)); 37 | 38 | handleSitesGet() { 39 | respond(sites); 40 | } 41 | 42 | handleSitePut() { 43 | UTF8.decodeStream(request).then(decode).then((body) { 44 | print("REQUEST PAYLOAD: $body"); 45 | 46 | if (body["name"].isEmpty) { 47 | respond({ 48 | "errors" : ["Name must be present"] 49 | }, 422); 50 | } else { 51 | sites.removeWhere((s) => s["id"] == siteId(siteReg)); 52 | sites.add(body); 53 | respond({}); 54 | } 55 | }); 56 | } 57 | 58 | handlePostsGet() { 59 | respond(posts.where((p) => p["siteId"] == siteId(postsReg)).toList()); 60 | } 61 | 62 | handlePostDelete() { 63 | posts.removeWhere((p) => p["id"] == postId(postReg)); 64 | respond({}); 65 | } 66 | 67 | 68 | 69 | if ('/api/sites' == p) { 70 | handleSitesGet(); 71 | 72 | } else if (siteReg.hasMatch(p)) { 73 | if (request.method == "PUT") handleSitePut(); 74 | 75 | } else if (postsReg.hasMatch(p)) { 76 | handlePostsGet(); 77 | 78 | } else if (postReg.hasMatch(p)) { 79 | if (request.method == "DELETE") handlePostDelete(); 80 | } 81 | } 82 | 83 | main() { 84 | HttpServer.bind("127.0.0.1", 3001).then((server){ 85 | final vDir = new VirtualDirectory(Platform.script.resolve('./web').toFilePath()) 86 | ..followLinks = true 87 | ..allowDirectoryListing = true 88 | ..jailRoot = false; 89 | 90 | server.listen((request) { 91 | if(request.uri.path.startsWith('/api')) { 92 | handleRequest(request); 93 | } else { 94 | vDir.serveRequest(request); 95 | } 96 | }); 97 | }); 98 | } -------------------------------------------------------------------------------- /demo/web/app.dart: -------------------------------------------------------------------------------- 1 | library hammock_demo_app; 2 | 3 | import 'package:angular/angular.dart'; 4 | import 'package:angular/application_factory.dart'; 5 | import 'package:hammock/hammock.dart'; 6 | import 'util/mirror_based_serializers.dart'; 7 | import 'dart:async'; 8 | 9 | 10 | 11 | //----------------------- 12 | //--------MODELS--------- 13 | //----------------------- 14 | 15 | // Any Dart object can be used as a model. No special interfaces or classes are required. 16 | // For example, here Site and Post have no knowledge of Hammock or Angular. 17 | class Site { 18 | int id; 19 | String name; 20 | List posts; 21 | Site(this.id, this.name, this.posts); 22 | 23 | remove(Post post) => posts.remove(post); 24 | } 25 | 26 | class Post { 27 | int id; 28 | String title; 29 | int views; 30 | Post(this.id, this.title, this.views); 31 | 32 | get popular => views > 100; 33 | } 34 | 35 | 36 | 37 | //----------------------- 38 | //------COMPONENTS------- 39 | //----------------------- 40 | @Component(selector: 'post', templateUrl: 'partials/post.html', publishAs: 'ctrl') 41 | class PostComponent { 42 | @NgOneWay("post") Post post; 43 | @NgOneWay("site") Site site; 44 | 45 | ObjectStore store; 46 | PostComponent(this.store); 47 | 48 | void delete() { 49 | // Hammock does not track associations, so we have to do it ourselves. 50 | siteStore.delete(post).then((_) => site.remove(post)); 51 | } 52 | 53 | get siteStore => store.scope(site); 54 | } 55 | 56 | @Component(selector: 'site', templateUrl: 'partials/site.html', publishAs: 'ctrl') 57 | class SiteComponent { 58 | @NgOneWay("site") Site site; 59 | 60 | ObjectStore store; 61 | SiteComponent(this.store); 62 | 63 | void update() { 64 | // This is an example of handling success and error cases differently. 65 | store.update(site).then( 66 | (_) => print("success!"), 67 | onError: (errors) => print("errors $errors") 68 | ); 69 | } 70 | } 71 | 72 | @Component(selector: 'app', templateUrl: 'partials/app.html', publishAs: 'app') 73 | class App { 74 | List sites; 75 | 76 | App(ObjectStore store) { 77 | store.list(Site).then((sites) => this.sites = sites); 78 | } 79 | } 80 | 81 | 82 | //----------------------- 83 | //------SERIALIZERS------ 84 | //----------------------- 85 | 86 | // A simple function can be used as a deserializer. 87 | parseErrors(obj, CommandResponse resp) => resp.content["errors"]; 88 | 89 | // If it get tedious, we can always use some library removing the boilerplate. 90 | final serializePost = serializer("posts", ["id", "title", "views"]); 91 | final deserializePost = deserializer(Post, ["id", "title", "views"]); 92 | final serializeSite = serializer("sites", ["id", "name"]); 93 | 94 | // Some deserializers are quite complex and may require other injectables. 95 | @Injectable() 96 | class DeserializeSite { 97 | ObjectStore store; 98 | DeserializeSite(this.store); 99 | 100 | call(Resource r) { 101 | final site = new Site(r.id, r.content["name"], []); 102 | 103 | // Since a Deserializer can return a future, 104 | // you can load all the associations right here. 105 | return store.scope(site).list(Post).then((posts) { 106 | site.posts = posts; 107 | return site; 108 | }); 109 | } 110 | } 111 | 112 | 113 | 114 | createHammockConfig(Injector inj) { 115 | return new HammockConfig(inj) 116 | ..set({ 117 | "posts" : { 118 | "type" : Post, 119 | "serializer" : serializePost, 120 | "deserializer": {"query" : deserializePost} 121 | }, 122 | 123 | "sites" : { 124 | "type" : Site, 125 | "serializer" : serializeSite, 126 | "deserializer": { 127 | "query" : DeserializeSite, //When given a type, Hammock will use the Injector to get an instance of it. 128 | "command" : { 129 | "success" : null, 130 | "error" : parseErrors 131 | } 132 | } 133 | } 134 | }) 135 | ..urlRewriter.baseUrl = '/api'; 136 | } 137 | 138 | 139 | 140 | //----------------------- 141 | //---------MAIN---------- 142 | //----------------------- 143 | main () { 144 | final module = new Module() 145 | ..install(new Hammock()) 146 | ..bind(App) 147 | ..bind(SiteComponent) 148 | ..bind(PostComponent) 149 | ..bind(DeserializeSite) 150 | ..bind(HammockConfig, toFactory: createHammockConfig); 151 | 152 | applicationFactory().addModule(module).run(); 153 | } -------------------------------------------------------------------------------- /demo/web/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

Hammock Demo Application

9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/web/partials/app.html: -------------------------------------------------------------------------------- 1 |

List of Sites

2 | 3 | -------------------------------------------------------------------------------- /demo/web/partials/post.html: -------------------------------------------------------------------------------- 1 |
  • 2 | {{ctrl.post.title}} 3 | 4 | (Views: {{ctrl.post.views}}) 5 | 6 | 7 |
  • -------------------------------------------------------------------------------- /demo/web/partials/site.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 5 |
    6 | 7 | 8 |
    -------------------------------------------------------------------------------- /demo/web/util/mirror_based_serializers.dart: -------------------------------------------------------------------------------- 1 | library mirror_based_serializers; 2 | 3 | import 'dart:mirrors'; 4 | import 'package:hammock/hammock.dart'; 5 | 6 | serializer(type, attrs) { 7 | return (obj) { 8 | final m = reflect(obj); 9 | 10 | final id = m.getField(#id).reflectee; 11 | final content = attrs.fold({}, (res, attr) { 12 | res[attr] = m.getField(new Symbol(attr)).reflectee; 13 | return res; 14 | }); 15 | 16 | return resource(type, id, content); 17 | }; 18 | } 19 | 20 | deserializer(type, attrs) { 21 | return (r) { 22 | final params = attrs.fold([], (res, attr) => res..add(r.content[attr])); 23 | return reflectClass(type).newInstance(const Symbol(''), params).reflectee; 24 | }; 25 | } -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | basePath: '.', 4 | frameworks: ['dart-unittest'], 5 | 6 | files: [ 7 | 'test/hammock_test.dart', 8 | 'packages/guinness/init_specs.dart', 9 | {pattern: 'lib/**/*.dart', watched: true, included: false, served: true}, 10 | {pattern: 'test/**/*.dart', watched: true, included: false, served: true}, 11 | {pattern: 'packages/**/*.dart', watched: true, included: false, served: true} 12 | ], 13 | 14 | autoWatch: true, 15 | captureTimeout: 20000, 16 | browserNoActivityTimeout: 1500000, 17 | 18 | plugins: [ 19 | 'karma-dart', 20 | 'karma-chrome-launcher', 21 | 'karma-phantomjs-launcher' 22 | ], 23 | 24 | browsers: ['Dartium'] 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/hammock.dart: -------------------------------------------------------------------------------- 1 | library hammock; 2 | 3 | import 'package:angular/angular.dart'; 4 | import 'dart:async'; 5 | import 'hammock_core.dart'; 6 | export 'hammock_core.dart'; 7 | 8 | part 'src/resource_store.dart'; 9 | part 'src/config.dart'; 10 | part 'src/request_defaults.dart'; 11 | part 'src/custom_request_params.dart'; 12 | part 'src/object_store.dart'; 13 | part 'src/utils.dart'; 14 | 15 | class Hammock extends Module { 16 | Hammock() { 17 | bind(HammockConfig); 18 | bind(ResourceStore); 19 | bind(ObjectStore); 20 | } 21 | } -------------------------------------------------------------------------------- /lib/hammock_core.dart: -------------------------------------------------------------------------------- 1 | library hammock_core; 2 | 3 | import 'dart:convert'; 4 | import 'dart:collection'; 5 | 6 | class Resource { 7 | final Object type, id; 8 | final Map content; 9 | 10 | Resource(this.type, this.id, this.content); 11 | } 12 | 13 | Resource resource(type, id, [content]) => new Resource(type, id, content); 14 | 15 | class CommandResponse { 16 | final Resource resource; 17 | final content; 18 | CommandResponse(this.resource, this.content); 19 | } 20 | 21 | class QueryResult extends Object with ListMixin { 22 | final List list; 23 | final Map meta; 24 | 25 | QueryResult(this.list, [this.meta=const {}]); 26 | 27 | T operator[](index) => list[index]; 28 | int get length => list.length; 29 | 30 | operator[]=(index,value) => throw "Not Implemented"; 31 | set length(value) => throw "Not Implemented"; 32 | 33 | QueryResult map(Function fn) => new QueryResult(list.map(fn).toList(), meta); 34 | 35 | QueryResult toList({ bool growable: true }) => this; 36 | } 37 | 38 | abstract class DocumentFormat { 39 | String resourceToDocument(Resource res); 40 | Resource documentToResource(resourceType, document); 41 | QueryResult documentToManyResources(resourceType, document); 42 | CommandResponse documentToCommandResponse(Resource res, document); 43 | } 44 | 45 | abstract class JsonDocumentFormat implements DocumentFormat { 46 | resourceToJson(Resource resource); 47 | Resource jsonToResource(resourceType, json); 48 | QueryResult jsonToManyResources(resourceType, json); 49 | 50 | final _encoder = new JsonEncoder(); 51 | final _decoder = new JsonDecoder(); 52 | 53 | String resourceToDocument(Resource res) => 54 | _encoder.convert(resourceToJson(res)); 55 | 56 | Resource documentToResource(resourceType, document) => 57 | jsonToResource(resourceType, _toJSON(document)); 58 | 59 | QueryResult documentToManyResources(resourceType, document) => 60 | jsonToManyResources(resourceType, _toJSON(document)); 61 | 62 | CommandResponse documentToCommandResponse(Resource res, document) => 63 | new CommandResponse(res, _toJSON(document)); 64 | 65 | _toJSON(document) { 66 | try { 67 | return (document is String) ? _decoder.convert(document) : document; 68 | } on FormatException catch(e) { 69 | return document; 70 | } 71 | } 72 | } 73 | 74 | class SimpleDocumentFormat extends JsonDocumentFormat { 75 | resourceToJson(Resource res) => 76 | res.content; 77 | 78 | Resource jsonToResource(type, json) => 79 | resource(type, json["id"], json); 80 | 81 | QueryResult jsonToManyResources(type, json) => 82 | new QueryResult(json.map((j) => jsonToResource(type, j)).toList()); 83 | } -------------------------------------------------------------------------------- /lib/src/config.dart: -------------------------------------------------------------------------------- 1 | part of hammock; 2 | 3 | class HammockUrlRewriter implements UrlRewriter { 4 | String baseUrl = ""; 5 | String suffix = ""; 6 | String call(String url) => "$baseUrl$url$suffix"; 7 | } 8 | 9 | @Injectable() 10 | class HammockConfig { 11 | Map config = {}; 12 | DocumentFormat documentFormat = new SimpleDocumentFormat(); 13 | final RequestDefaults requestDefaults = new RequestDefaults(); 14 | dynamic urlRewriter = new HammockUrlRewriter(); 15 | 16 | final Injector injector; 17 | HammockConfig(this.injector); 18 | 19 | void set(Map config){ 20 | this.config = config; 21 | } 22 | 23 | String route(resourceType) => 24 | _value([resourceType, 'route'], () => resourceType); 25 | 26 | deserializer(resourceType, [List path=const[]]) => 27 | _load(_value([resourceType, 'deserializer']..addAll(path))); 28 | 29 | serializer(resourceType) => 30 | _load(_value([resourceType, 'serializer'], () => throw "No serializer for `${resourceType}`")); 31 | 32 | resourceType(objectType) => 33 | config.keys.firstWhere( 34 | (e) => _value([e, "type"]) == objectType, 35 | orElse: () => throw "No resource type found for $objectType"); 36 | 37 | _value(List path, [ifAbsent=_null]) { 38 | path = path.where((_) => _ != null).toList(); 39 | 40 | var current = config; 41 | for(var i = 0; i < path.length; ++i) { 42 | if( current is! Map ) break; 43 | if (current.containsKey(path[i])) { 44 | current = current[path[i]]; 45 | } else { 46 | current = null; 47 | } 48 | } 49 | 50 | return current == null ? ifAbsent() : current; 51 | } 52 | 53 | _defaultUpdater(resourceType) => 54 | (object, resource) => deserializer(resourceType)(resource); 55 | 56 | _load(obj) => 57 | (obj is Type) ? injector.get(obj) : obj; 58 | } 59 | 60 | _null() => null; -------------------------------------------------------------------------------- /lib/src/custom_request_params.dart: -------------------------------------------------------------------------------- 1 | part of hammock; 2 | 3 | class CustomRequestParams { 4 | final String url; 5 | final String method; 6 | final data; 7 | final Map params; 8 | final Map headers; 9 | final bool withCredentials; 10 | final xsrfHeaderName; 11 | final xsrfCookieName; 12 | final interceptors; 13 | final cache; 14 | final timeout; 15 | 16 | const CustomRequestParams({ 17 | this.url, 18 | this.method, 19 | this.data, 20 | this.params, 21 | this.headers, 22 | this.withCredentials: false, 23 | this.xsrfHeaderName, 24 | this.xsrfCookieName, 25 | this.interceptors, 26 | this.cache, 27 | this.timeout 28 | }); 29 | 30 | Future invoke(http) => 31 | http(method: method, url: url, data: data, params: params, headers: headers, 32 | withCredentials: withCredentials, xsrfHeaderName: xsrfHeaderName, 33 | xsrfCookieName: xsrfCookieName, interceptors: interceptors, cache: cache, 34 | timeout: timeout); 35 | } -------------------------------------------------------------------------------- /lib/src/object_store.dart: -------------------------------------------------------------------------------- 1 | part of hammock; 2 | 3 | typedef CommandDeserializer(obj, resp); 4 | 5 | @Injectable() 6 | class ObjectStore { 7 | ResourceStore resourceStore; 8 | HammockConfig config; 9 | 10 | ObjectStore(this.resourceStore, this.config); 11 | 12 | ObjectStore scope(obj) => 13 | new ObjectStore(resourceStore.scope(_wrapInResource(obj)), config); 14 | 15 | 16 | Future one(type, id) => 17 | _resourceQueryOne(type, (rt) => resourceStore.one(rt, id)); 18 | 19 | Future list(type, {Map params}) => 20 | _resourceQueryList(type, (rt) => resourceStore.list(rt, params: params)); 21 | 22 | Future customQueryOne(type, CustomRequestParams params) => 23 | _resourceQueryOne(type, (rt) => resourceStore.customQueryOne(rt, params)); 24 | 25 | Future customQueryList(type, CustomRequestParams params) => 26 | _resourceQueryList(type, (rt) => resourceStore.customQueryList(rt, params)); 27 | 28 | 29 | Future create(object) => 30 | _resourceStoreCommand(object, resourceStore.create); 31 | 32 | Future update(object) => 33 | _resourceStoreCommand(object, resourceStore.update); 34 | 35 | Future delete(object) => 36 | _resourceStoreCommand(object, resourceStore.delete); 37 | 38 | Future customCommand(object, CustomRequestParams params) => 39 | _resourceStoreCommand(object, (res) => resourceStore.customCommand(res, params)); 40 | 41 | 42 | _resourceQueryOne(type, function) { 43 | final rt = config.resourceType(type); 44 | final deserialize = config.deserializer(rt, ['query']); 45 | return function(rt).then(deserialize); 46 | } 47 | 48 | _resourceQueryList(type, function) { 49 | final rt = config.resourceType(type); 50 | deserialize(list) => _wrappedListIntoFuture(list.map(config.deserializer(rt, ['query']))); 51 | return function(rt).then(deserialize); 52 | } 53 | 54 | _resourceStoreCommand(object, function) { 55 | final res = _wrapInResource(object); 56 | final p = _parseSuccessCommandResponse(res, object); 57 | final ep = _parseErrorCommandResponse(res, object); 58 | return function(res).then(p, onError: ep); 59 | } 60 | 61 | _wrapInResource(object) => 62 | config.serializer(config.resourceType(object.runtimeType))(object); 63 | 64 | _parseSuccessCommandResponse(res, object) => 65 | _commandResponse(res, object, ['command', 'success']); 66 | 67 | _parseErrorCommandResponse(res, object) => 68 | (resp) => _wrappedIntoErrorFuture(_commandResponse(res, object, ['command', 'error'])(resp)); 69 | 70 | _commandResponse(res, object, path) { 71 | final d = config.deserializer(res.type, path); 72 | if (d == null) { 73 | return (resp) => resp; 74 | } else if (d is CommandDeserializer) { 75 | return (resp) => d(object, resp); 76 | } else { 77 | return (resp) => d(resource(res.type, res.id, resp.content)); 78 | } 79 | } 80 | 81 | } -------------------------------------------------------------------------------- /lib/src/request_defaults.dart: -------------------------------------------------------------------------------- 1 | part of hammock; 2 | 3 | class RequestDefaults { 4 | Map params; 5 | Map headers; 6 | bool withCredentials; 7 | String xsrfHeaderName; 8 | String xsrfCookieName; 9 | var interceptors; 10 | var cache; 11 | var timeout; 12 | 13 | RequestDefaults({ 14 | this.params, 15 | this.headers, 16 | this.withCredentials: false, 17 | this.xsrfHeaderName, 18 | this.xsrfCookieName, 19 | this.interceptors, 20 | this.cache, 21 | this.timeout 22 | }); 23 | } -------------------------------------------------------------------------------- /lib/src/resource_store.dart: -------------------------------------------------------------------------------- 1 | part of hammock; 2 | 3 | @Injectable() 4 | class ResourceStore { 5 | final Http http; 6 | final HammockConfig config; 7 | final List scopingResources; 8 | 9 | ResourceStore(this.http, this.config) 10 | : scopingResources = []; 11 | 12 | ResourceStore.copy(ResourceStore original) 13 | : this.scopingResources = new List.from(original.scopingResources), 14 | this.http = original.http, 15 | this.config = original.config; 16 | 17 | ResourceStore scope(scopingResource) => new ResourceStore.copy(this)..scopingResources.add(scopingResource); 18 | 19 | 20 | Future one(resourceType, resourceId) { 21 | final url = _url(resourceType, resourceId); 22 | return _invoke("GET", url).then(_parseResource(resourceType)); 23 | } 24 | 25 | Future> list(resourceType, {Map params}) { 26 | final url = _url(resourceType); 27 | return _invoke("GET", url, params: params).then(_parseManyResources((resourceType))); 28 | } 29 | 30 | Future customQueryOne(resourceType, CustomRequestParams params) => 31 | params.invoke(http).then(_parseResource(resourceType)); 32 | 33 | Future> customQueryList(resourceType, CustomRequestParams params) => 34 | params.invoke(http).then(_parseManyResources(resourceType)); 35 | 36 | 37 | Future create(Resource resource) { 38 | final content = _docFormat.resourceToDocument(resource); 39 | final url = _url(resource.type); 40 | final p = _parseCommandResponse(resource); 41 | return _invoke("POST", url, data: content).then(p, onError: _error(p)); 42 | } 43 | 44 | Future update(Resource resource) { 45 | final content = _docFormat.resourceToDocument(resource); 46 | final url = _url(resource.type, resource.id); 47 | final p = _parseCommandResponse(resource); 48 | return _invoke("PUT", url, data: content).then(p, onError: _error(p)); 49 | } 50 | 51 | Future delete(Resource resource) { 52 | final url = _url(resource.type, resource.id); 53 | final p = _parseCommandResponse(resource); 54 | return _invoke("DELETE", url).then(p, onError: _error(p)); 55 | } 56 | 57 | Future customCommand(Resource resource, CustomRequestParams params) { 58 | final p = _parseCommandResponse(resource); 59 | return params.invoke(http).then(p, onError: _error(p)); 60 | } 61 | 62 | _invoke(String method, String url, {String data, Map params}) { 63 | final d = config.requestDefaults; 64 | return http.call( 65 | method: method, 66 | url: url, 67 | data: data, 68 | params: _paramsWithDefaults(params), 69 | headers: d.headers, 70 | withCredentials: d.withCredentials, 71 | xsrfCookieName: d.xsrfCookieName, 72 | xsrfHeaderName: d.xsrfHeaderName, 73 | interceptors: d.interceptors, 74 | cache: d.cache, 75 | timeout: d.timeout 76 | ); 77 | } 78 | 79 | _paramsWithDefaults(Map rParams) { 80 | if (config.requestDefaults.params == null && rParams == null) return null; 81 | final params = config.requestDefaults.params == null ? {} : config.requestDefaults.params; 82 | if (rParams != null) rParams.forEach((key, value) => params[key] = value); 83 | return params; 84 | } 85 | 86 | _parseResource(resourceType) => (resp) => _docFormat.documentToResource(resourceType, resp.data); 87 | _parseManyResources(resourceType) => (resp) => _docFormat.documentToManyResources(resourceType, resp.data); 88 | _parseCommandResponse(res) => (resp) => _docFormat.documentToCommandResponse(res, resp.data); 89 | _error(Function func) => (resp) => new Future.error(func(resp)); 90 | 91 | get _docFormat => config.documentFormat; 92 | 93 | _url(type, [id=_u]) { 94 | final parentFragment = scopingResources.map((r) => "/${config.route(r.type)}/${r.id}").join(""); 95 | final currentFragment = "/${config.route(type)}"; 96 | final idFragment = (id != _u) ? "/$id" : ""; 97 | return config.urlRewriter("$parentFragment$currentFragment$idFragment"); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | part of hammock; 2 | 3 | class _Undefined { 4 | const _Undefined(); 5 | } 6 | const _u = const _Undefined(); 7 | 8 | _wrappedIntoErrorFuture(res) { 9 | if (res is Future) { 10 | return res.then((r) => new Future.error(r)); 11 | } else { 12 | return new Future.error(res); 13 | } 14 | } 15 | 16 | _wrappedListIntoFuture(List list) { 17 | if (list.any((v) => v is Future)) { 18 | final wrappedInFutures = list.map((v) => v is Future ? v : new Future.value(v)); 19 | return Future.wait(wrappedInFutures); 20 | } else { 21 | return list; 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hammock", 3 | "repository": { 4 | "type": "git", 5 | "url": "https://github.com/vsavkin/hammock.git" 6 | }, 7 | "devDependencies": { 8 | "karma" : "~0.12.0", 9 | "karma-dart" : "~0.2.6", 10 | "karma-chrome-launcher": "~0.1.3", 11 | "karma-phantomjs-launcher": "~0.1.3", 12 | "karma-junit-reporter": "~0.2.1", 13 | "karma-jasmine" : "~0.1.5" 14 | } 15 | } -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: hammock 2 | version: 0.4.0 3 | author: Victor Savkin 4 | homepage: https://github.com/vsavkin/hammock 5 | description: AngularDart service for working with Rest APIs 6 | dependencies: 7 | angular: '>=1.0.0 <2.0.0' 8 | di: '>=3.3.1 <4.0.0' 9 | dev_dependencies: 10 | guinness: '>=0.1.10 <0.2.0' 11 | mock: '>0.11.0 <1.0.0' 12 | environment: 13 | sdk: ">=1.4.0 <2.0.0" -------------------------------------------------------------------------------- /scripts/travis/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | DART_DIST=dartsdk-linux-x64-release.zip 6 | DARTIUM_DIST=dartium-linux-x64-release.zip 7 | 8 | 9 | echo ------------------- 10 | echo Fetching dart sdk 11 | echo ------------------- 12 | 13 | curl http://storage.googleapis.com/dart-archive/channels/stable/release/latest/sdk/$DART_DIST > $DART_DIST 14 | 15 | 16 | echo ------------------- 17 | echo Fetching dartium 18 | echo ------------------- 19 | 20 | curl http://storage.googleapis.com/dart-archive/channels/stable/raw/latest/dartium/$DARTIUM_DIST > $DARTIUM_DIST 21 | 22 | 23 | unzip $DART_DIST > /dev/null 24 | unzip $DARTIUM_DIST > /dev/null 25 | rm $DART_DIST 26 | rm $DARTIUM_DIST 27 | mv dartium-* dartium; 28 | 29 | export DART_SDK="$PWD/dart-sdk" 30 | export PATH="$DART_SDK/bin:$PATH" 31 | export DARTIUM_BIN="$PWD/dartium/chrome" 32 | 33 | 34 | echo ------------------- 35 | echo Pub install 36 | echo ------------------- 37 | pub install 38 | 39 | 40 | echo ------------------- 41 | echo Dart analyzer 42 | echo ------------------- 43 | dartanalyzer lib/hammock.dart | grep "No issues found" 44 | 45 | if [ $? -ne 0 ]; then 46 | echo Dart analyzer failed 47 | dartanalyzer lib/hammock.dart 48 | exit 1 49 | fi 50 | 51 | 52 | echo ------------------- 53 | echo Karma-Dart 54 | echo ------------------- 55 | sh -e /etc/init.d/xvfb start 56 | ./node_modules/karma/bin/karma start --single-run --browsers Dartium 57 | 58 | echo ------------------- 59 | echo Karma-JS 60 | echo ------------------- 61 | echo ./node_modules/karma/bin/karma start --single-run --browsers PhantomJS -------------------------------------------------------------------------------- /test/angular_guinness.dart: -------------------------------------------------------------------------------- 1 | library angular_guinness; 2 | 3 | import 'package:angular/angular.dart'; 4 | import 'package:angular/mock/module.dart'; 5 | import 'package:hammock/hammock.dart'; 6 | import 'package:guinness/guinness.dart' as gns; 7 | 8 | export 'package:guinness/guinness.dart'; 9 | export 'package:unittest/unittest.dart' hide expect; 10 | export 'package:hammock/hammock.dart'; 11 | export 'package:angular/angular.dart'; 12 | export 'package:angular/mock/module.dart'; 13 | 14 | void registerBindings([bindings=const[]]) { 15 | gns.beforeEach(module((Module m) => bindings.forEach(m.bind))); 16 | } 17 | 18 | void beforeEach(Function fn) { 19 | gns.beforeEach(_injectify(fn)); 20 | } 21 | 22 | void afterEach(Function fn) { 23 | gns.afterEach(_injectify(fn)); 24 | } 25 | 26 | void it(String name, Function fn) { 27 | gns.it(name, _injectify(fn)); 28 | } 29 | 30 | void iit(String name, Function fn) { 31 | gns.iit(name, _injectify(fn)); 32 | } 33 | 34 | void xit(String name, Function fn) { 35 | gns.xit(name, _injectify(fn)); 36 | } 37 | 38 | setUpAngular() { 39 | gns.beforeEach(setUpInjector); 40 | gns.afterEach(tearDownInjector); 41 | gns.beforeEach(module((Module m) => m.install(new Hammock()))); 42 | } 43 | _injectify(Function fn) => async(inject(fn)); -------------------------------------------------------------------------------- /test/hammock_test.dart: -------------------------------------------------------------------------------- 1 | library hammock_test; 2 | 3 | import 'angular_guinness.dart'; 4 | import 'dart:convert'; 5 | import 'dart:async'; 6 | 7 | part 'src/resource_store_test.dart'; 8 | part 'src/object_store_test.dart'; 9 | part 'src/config_test.dart'; 10 | part 'src/integration_test.dart'; 11 | 12 | main() { 13 | testConfig(); 14 | testResourceStore(); 15 | testObjectStore(); 16 | testIntegration(); 17 | } 18 | 19 | 20 | wait(future, [callback]) { 21 | callback = callback != null ? callback : (_) {}; 22 | 23 | microLeap(); 24 | inject((MockHttpBackend http) => http.flush()); 25 | 26 | future.then(callback); 27 | } 28 | 29 | waitForError(future, [callback]) { 30 | callback = callback != null ? callback : (_) {}; 31 | 32 | microLeap(); 33 | inject((MockHttpBackend http) => http.flush()); 34 | 35 | future.catchError(callback); 36 | } -------------------------------------------------------------------------------- /test/src/config_test.dart: -------------------------------------------------------------------------------- 1 | part of hammock_test; 2 | 3 | testConfig() { 4 | describe("HammockConfig", () { 5 | setUpAngular(); 6 | 7 | it("returns a route for a resource type", (HammockConfig c) { 8 | c.set({"type" : {"route" : "aaa"}}); 9 | 10 | expect(c.route("type")).toEqual("aaa"); 11 | }); 12 | 13 | it("defaults the route to the given resource type", (HammockConfig c) { 14 | expect(c.route("type")).toEqual("type"); 15 | }); 16 | 17 | it("returns a serializer for a resource type", (HammockConfig c) { 18 | c.set({"type" : {"serializer" : "serializer"}}); 19 | 20 | expect(c.serializer("type")).toEqual("serializer"); 21 | }); 22 | 23 | it("throws when there is no serializer", (HammockConfig c) { 24 | expect(() => c.serializer("type")).toThrow(); 25 | }); 26 | 27 | it("returns a deserializer for a resource type", (HammockConfig c) { 28 | c.set({"type" : {"deserializer" : "deserializer"}}); 29 | 30 | expect(c.deserializer("type", [])).toEqual("deserializer"); 31 | }); 32 | 33 | it("returns a deserializer for a resource type (nested)", (HammockConfig c) { 34 | c.set({"type" : {"deserializer" : {"query" : "deserializer"}}}); 35 | 36 | expect(c.deserializer("type", ['query'])).toEqual("deserializer"); 37 | }); 38 | 39 | it("returns null when there is no deserializer", (HammockConfig c) { 40 | expect(c.deserializer("type", [])).toBeNull(); 41 | }); 42 | 43 | it("returns a resource type for an object type", (HammockConfig c) { 44 | c.set({"resourceType" : {"type" : "someType"}}); 45 | 46 | expect(c.resourceType("someType")).toEqual("resourceType"); 47 | }); 48 | 49 | it("throws when no resource type is found", (HammockConfig c) { 50 | expect(() => c.resourceType("someType")).toThrowWith(message: "No resource type found"); 51 | }); 52 | 53 | describe("when given types", () { 54 | registerBindings([_TestInjectable]); 55 | 56 | it("uses Injector to instantiate serializers and deserializers", (HammockConfig c) { 57 | c.set({ 58 | "type" : { 59 | "serializer" : _TestInjectable, 60 | "deserializer" : _TestInjectable 61 | } 62 | }); 63 | 64 | expect(c.serializer("type")).toBeA(_TestInjectable); 65 | expect(c.deserializer("type")).toBeA(_TestInjectable); 66 | }); 67 | }); 68 | }); 69 | } 70 | 71 | class _TestInjectable { 72 | ObjectStore store; 73 | _TestInjectable(this.store); 74 | } -------------------------------------------------------------------------------- /test/src/integration_test.dart: -------------------------------------------------------------------------------- 1 | part of hammock_test; 2 | 3 | class IntegrationPost { 4 | int id; 5 | String title; 6 | String errors; 7 | } 8 | 9 | testIntegration() { 10 | setUpAngular(); 11 | 12 | deserializePost(r) => new IntegrationPost() 13 | ..id = r.id 14 | ..title = r.content["title"] 15 | ..errors = r.content["errors"]; 16 | 17 | serializePost(post) => 18 | resource("posts", post.id, {"id" : post.id, "title" : post.title}); 19 | 20 | 21 | 22 | describe("Custom Document Formats", () { 23 | it("can support jsonapi.org format", (HammockConfig config, MockHttpBackend hb, ResourceStore s){ 24 | config.documentFormat = new JsonApiOrgFormat(); 25 | 26 | hb.whenGET("/posts/123").respond({"posts" : [{"id" : 123, "title" : "title"}]}); 27 | wait(s.one("posts", 123), (post) { 28 | expect(post.content["title"]).toEqual("title"); 29 | }); 30 | 31 | hb.whenPUT("/posts/123").respond({"posts":[{"id":123,"title":"new"}]}); 32 | wait(s.update(resource("posts", 123, {"id" : 123, "title" : "new"}))); 33 | }); 34 | }); 35 | 36 | describe("Different Types of Responses", () { 37 | final post = new IntegrationPost()..id = 123..title = "new"; 38 | 39 | it("works when when a server returns an updated resource", 40 | (HammockConfig config, MockHttpBackend hb, ObjectStore s) { 41 | 42 | config.set({ 43 | "posts" : { 44 | "type" : IntegrationPost, 45 | "serializer" : serializePost, 46 | "deserializer" : deserializePost 47 | } 48 | }); 49 | 50 | hb.expectPUT("/posts/123").respond({"id" : 123, "title" : "updated"}); 51 | 52 | wait(s.update(post), (up) { 53 | expect(up.title).toEqual("updated"); 54 | }); 55 | 56 | hb.expectPUT("/posts/123").respond(422, {"id" : 123, "title" : "updated", "errors" : "some errors"}, {}); 57 | 58 | waitForError(s.update(post), (up, s) { 59 | expect(up.title).toEqual("updated"); 60 | expect(up.errors).toEqual("some errors"); 61 | }); 62 | }); 63 | 64 | it("works when a server returns a status", 65 | (HammockConfig config, MockHttpBackend hb, ObjectStore s) { 66 | 67 | config.set({ 68 | "posts" : { 69 | "type" : IntegrationPost, 70 | "serializer" : serializePost, 71 | "deserializer" : { 72 | "command" : { 73 | "success" : (obj, r) => true, 74 | "error" : (obj, r) => r.content["errors"] 75 | } 76 | } 77 | } 78 | }); 79 | 80 | 81 | hb.expectPUT("/posts/123").respond("OK"); 82 | 83 | wait(s.update(post), (res) { 84 | expect(res).toBeTrue(); 85 | }); 86 | 87 | hb.expectPUT("/posts/123").respond(422, {"errors" : "some errors"}, {}); 88 | 89 | waitForError(s.update(post), (errors) { 90 | expect(errors).toEqual("some errors"); 91 | }); 92 | }); 93 | }); 94 | } 95 | 96 | class JsonApiOrgFormat extends JsonDocumentFormat { 97 | resourceToJson(Resource res) => 98 | {res.type.toString(): [res.content]}; 99 | 100 | Resource jsonToResource(type, json) => 101 | resource(type, json[type][0]["id"], json[type][0]); 102 | 103 | QueryResult jsonToManyResources(type, json) => 104 | json[type].map((r) => resource(type, r["id"], r)).toList(); 105 | } -------------------------------------------------------------------------------- /test/src/object_store_test.dart: -------------------------------------------------------------------------------- 1 | part of hammock_test; 2 | 3 | testObjectStore() { 4 | describe("ObjectStore", () { 5 | setUpAngular(); 6 | 7 | describe("Queries", () { 8 | beforeEach((HammockConfig config) { 9 | config.set({ 10 | "posts" : { 11 | "type" : Post, 12 | "serializer" : serializePost, 13 | "deserializer" : deserializePost 14 | }, 15 | "comments" : { 16 | "type": Comment, 17 | "deserializer" : deserializeComment 18 | } 19 | }); 20 | }); 21 | 22 | it("returns an object", (MockHttpBackend hb, ObjectStore store) { 23 | hb.whenGET("/posts/123").respond({"title" : "SampleTitle"}); 24 | 25 | wait(store.one(Post, 123), (Post post) { 26 | expect(post.title).toEqual("SampleTitle"); 27 | }); 28 | }); 29 | 30 | it("returns multiple objects", (MockHttpBackend hb, ObjectStore store) { 31 | hb.whenGET("/posts").respond([{"title" : "SampleTitle"}]); 32 | 33 | wait(store.list(Post), (List posts) { 34 | expect(posts.length).toEqual(1); 35 | expect(posts[0].title).toEqual("SampleTitle"); 36 | }); 37 | }); 38 | 39 | it("returns a nested object", (MockHttpBackend hb, ObjectStore store) { 40 | final post = new Post()..id = 123; 41 | hb.whenGET("/posts/123/comments/456").respond({"text" : "SampleComment"}); 42 | 43 | wait(store.scope(post).one(Comment, 456), (Comment comment) { 44 | expect(comment.text).toEqual("SampleComment"); 45 | }); 46 | }); 47 | 48 | it("handles errors", (MockHttpBackend hb, ObjectStore store) { 49 | hb.whenGET("/posts/123").respond(500, "BOOM"); 50 | 51 | waitForError(store.one(Post, 123), (resp) { 52 | expect(resp.data).toEqual("BOOM"); 53 | }); 54 | }); 55 | 56 | it("uses a separate deserializer for queries", 57 | (HammockConfig config, MockHttpBackend hb, ObjectStore store) { 58 | 59 | config.set({ 60 | "posts" : { 61 | "type" : Post, 62 | "deserializer" : { 63 | "query" : deserializePost 64 | } 65 | } 66 | }); 67 | 68 | hb.whenGET("/posts/123").respond({"title" : "SampleTitle"}); 69 | 70 | wait(store.one(Post, 123), (Post post) { 71 | expect(post.title).toEqual("SampleTitle"); 72 | }); 73 | }); 74 | 75 | it("supports deserializers that return Futures", 76 | (HammockConfig config, MockHttpBackend hb, ObjectStore store) { 77 | 78 | config.set({ 79 | "posts" : { 80 | "type" : Post, 81 | "deserializer" : (r) => new Future.value(deserializePost(r)) 82 | } 83 | }); 84 | 85 | hb.whenGET("/posts/123").respond({"title" : "SampleTitle"}); 86 | 87 | wait(store.one(Post, 123), (Post post) { 88 | expect(post.title).toEqual("SampleTitle"); 89 | }); 90 | 91 | hb.whenGET("/posts").respond([{"title" : "SampleTitle"}]); 92 | 93 | wait(store.list(Post), (List posts) { 94 | expect(posts.first.title).toEqual("SampleTitle"); 95 | }); 96 | }); 97 | 98 | it("support custom queries returning one object", (MockHttpBackend hb, ObjectStore store) { 99 | hb.whenGET("/posts/123").respond({"id": 123, "title" : "SampleTitle"}); 100 | 101 | wait(store.customQueryOne(Post, new CustomRequestParams(method: "GET", url:"/posts/123")), (Post post) { 102 | expect(post.title).toEqual("SampleTitle"); 103 | }); 104 | }); 105 | 106 | it("support custom queries returning many object", (MockHttpBackend hb, ObjectStore store) { 107 | hb.whenGET("/posts").respond([{"id": 123, "title" : "SampleTitle"}]); 108 | 109 | wait(store.customQueryList(Post, new CustomRequestParams(method: "GET", url: "/posts")), (List posts) { 110 | expect(posts.length).toEqual(1); 111 | expect(posts[0].title).toEqual("SampleTitle"); 112 | }); 113 | }); 114 | }); 115 | 116 | 117 | describe("Commands", () { 118 | describe("Without Deserializers", () { 119 | beforeEach((HammockConfig config) { 120 | config.set({ 121 | "posts" : { 122 | "type" : Post, 123 | "serializer" : serializePost 124 | }, 125 | "comments" : { 126 | "type" : Comment, 127 | "serializer" : serializeComment 128 | } 129 | }); 130 | }); 131 | 132 | it("creates an object", (MockHttpBackend hb, ObjectStore store) { 133 | hb.expectPOST("/posts", '{"id":null,"title":"New"}').respond({"id":123,"title":"New"}); 134 | 135 | final post = new Post()..title = "New"; 136 | 137 | wait(store.create(post)); 138 | }); 139 | 140 | it("updates an object", (MockHttpBackend hb, ObjectStore store) { 141 | hb.expectPUT("/posts/123", '{"id":123,"title":"New"}').respond({}); 142 | 143 | final post = new Post()..id = 123..title = "New"; 144 | 145 | wait(store.update(post)); 146 | }); 147 | 148 | it("deletes a object", (MockHttpBackend hb, ObjectStore store) { 149 | hb.expectDELETE("/posts/123").respond({}); 150 | 151 | final post = new Post()..id = 123; 152 | 153 | wait(store.delete(post)); 154 | }); 155 | 156 | it("updates a nested object", (MockHttpBackend hb, ObjectStore store) { 157 | hb.expectPUT("/posts/123/comments/456", '{"id":456,"text":"New"}').respond({}); 158 | 159 | final post = new Post()..id = 123; 160 | final comment = new Comment()..id = 456..text = "New"; 161 | 162 | wait(store.scope(post).update(comment)); 163 | }); 164 | 165 | it("handles errors", (MockHttpBackend hb, ObjectStore store) { 166 | hb.expectPOST("/posts", '{"id":null,"title":"New"}').respond(500, "BOOM", {}); 167 | 168 | final post = new Post()..title = "New"; 169 | 170 | waitForError(store.create(post)); 171 | }); 172 | 173 | it("supports custom commands", (MockHttpBackend hb, ObjectStore store) { 174 | hb.expectDELETE("/posts/123").respond("OK"); 175 | 176 | final post = new Post()..id = 123; 177 | 178 | wait(store.customCommand(post, new CustomRequestParams(method: 'DELETE', url: '/posts/123')), (resp) { 179 | expect(resp.content).toEqual("OK"); 180 | }); 181 | }); 182 | }); 183 | 184 | describe("With Deserializers", () { 185 | var post; 186 | 187 | beforeEach(() { 188 | post = new Post()..id = 123..title = "New"; 189 | }); 190 | 191 | it("uses the same deserializer for queries and commands", 192 | (MockHttpBackend hb, ObjectStore store, HammockConfig config) { 193 | 194 | config.set({ 195 | "posts" : { 196 | "type" : Post, 197 | "serializer" : serializePost, 198 | "deserializer" : deserializePost 199 | } 200 | }); 201 | 202 | hb.expectPUT("/posts/123").respond({"id": 123, "title": "Newer"}); 203 | 204 | wait(store.update(post), (Post returnedPost) { 205 | expect(returnedPost.id).toEqual(123); 206 | expect(returnedPost.title).toEqual("Newer"); 207 | }); 208 | }); 209 | 210 | it("uses a separate serializer for commands", 211 | (MockHttpBackend hb, ObjectStore store, HammockConfig config) { 212 | 213 | config.set({ 214 | "posts" : { 215 | "type" : Post, 216 | "serializer" : serializePost, 217 | "deserializer" : { 218 | "command" : updatePost 219 | } 220 | } 221 | }); 222 | 223 | hb.expectPUT("/posts/123").respond({"title": "Newer"}); 224 | 225 | wait(store.update(post), (Post returnedPost) { 226 | expect(returnedPost.title).toEqual("Newer"); 227 | expect(post.title).toEqual("Newer"); 228 | }); 229 | }); 230 | 231 | it("uses a separate serializer when a command fails", 232 | (MockHttpBackend hb, ObjectStore store, HammockConfig config) { 233 | 234 | config.set({ 235 | "posts" : { 236 | "type" : Post, 237 | "serializer" : serializePost, 238 | "deserializer" : { 239 | "command" : { 240 | "success" : deserializePost, 241 | "error" : parseErrors 242 | } 243 | } 244 | } 245 | }); 246 | 247 | hb.expectPUT("/posts/123").respond(500, "BOOM"); 248 | 249 | waitForError(store.update(post), (resp) { 250 | expect(resp).toEqual("BOOM"); 251 | }); 252 | }); 253 | 254 | it("supports deserializers that return Futures", 255 | (HammockConfig config, MockHttpBackend hb, ObjectStore store) { 256 | 257 | config.set({ 258 | "posts" : { 259 | "type" : Post, 260 | "serializer" : serializePost, 261 | "deserializer" : { 262 | "command" : { 263 | "success" : (r) => new Future.value(deserializePost(r)), 264 | "error" : (p,r) => new Future.value(parseErrors(p,r)) 265 | } 266 | } 267 | } 268 | }); 269 | 270 | hb.expectPUT("/posts/123").respond({"title": "Newer"}); 271 | 272 | wait(store.update(post), (Post returnedPost) { 273 | expect(returnedPost.title).toEqual("Newer"); 274 | }); 275 | 276 | hb.expectPUT("/posts/123").respond(500, 'BOOM'); 277 | 278 | waitForError(store.update(post), (resp) { 279 | expect(resp).toEqual("BOOM"); 280 | }); 281 | }); 282 | }); 283 | }); 284 | }); 285 | } 286 | 287 | class Post { 288 | int id; 289 | String title; 290 | } 291 | 292 | class Comment { 293 | int id; 294 | String text; 295 | } 296 | 297 | Post deserializePost(Resource r) => new Post() 298 | ..id = r.id 299 | ..title = r.content["title"]; 300 | 301 | Post updatePost(Post post, CommandResponse resp) { 302 | post.title = resp.content["title"]; 303 | return post; 304 | } 305 | 306 | parseErrors(Post post, CommandResponse resp) => 307 | resp.content; 308 | 309 | Resource serializePost(Post post) => 310 | resource("posts", post.id, {"id" : post.id, "title" : post.title}); 311 | 312 | Comment deserializeComment(Resource r) => new Comment() 313 | ..id = r.id 314 | ..text = r.content["text"]; 315 | 316 | Resource serializeComment(Comment comment) => 317 | resource("comments", comment.id, {"id" : comment.id, "text" : comment.text}); 318 | 319 | -------------------------------------------------------------------------------- /test/src/resource_store_test.dart: -------------------------------------------------------------------------------- 1 | part of hammock_test; 2 | 3 | testResourceStore() { 4 | describe("ResourceStore", () { 5 | setUpAngular(); 6 | 7 | describe("Queries", () { 8 | it("returns a resource", (MockHttpBackend hb, ResourceStore store) { 9 | hb.whenGET("/posts/123").respond({"id": 123, "title" : "SampleTitle"}); 10 | 11 | wait(store.one("posts", 123), (resource) { 12 | expect(resource.id).toEqual(123); 13 | expect(resource.content["title"]).toEqual("SampleTitle"); 14 | }); 15 | }); 16 | 17 | it("returns multiple resources", (MockHttpBackend hb, ResourceStore store) { 18 | hb.whenGET("/posts").respond([{"id": 123, "title" : "SampleTitle"}]); 19 | 20 | wait(store.list("posts"), (resources) { 21 | expect(resources.length).toEqual(1); 22 | expect(resources[0].content["title"]).toEqual("SampleTitle"); 23 | }); 24 | }); 25 | 26 | it("returns a nested resource", (MockHttpBackend hb, ResourceStore store) { 27 | hb.whenGET("/posts/123/comments/456").respond({"id": 456, "text" : "SampleComment"}); 28 | 29 | final post = resource("posts", 123); 30 | wait(store.scope(post).one("comments", 456), (resource) { 31 | expect(resource.id).toEqual(456); 32 | expect(resource.content["text"]).toEqual("SampleComment"); 33 | }); 34 | }); 35 | 36 | it("handles errors", (MockHttpBackend hb, ResourceStore store) { 37 | hb.whenGET("/posts/123").respond(500, "BOOM", {}); 38 | 39 | waitForError(store.one("posts", 123), (resp) { 40 | expect(resp.data).toBe("BOOM"); 41 | }); 42 | }); 43 | 44 | describe("default params", () { 45 | it("uses request defaults", (MockHttpBackend hb, HammockConfig config, 46 | ResourceStore store) { 47 | config.requestDefaults.withCredentials = true; 48 | 49 | hb.when("GET", "/posts/123", null, null, true).respond(200, {"id" : 123}); 50 | 51 | wait(store.one("posts", 123)); 52 | }); 53 | 54 | it("should merge params", (MockHttpBackend hb, HammockConfig config, 55 | ResourceStore store) { 56 | config.requestDefaults.params = {"defaultParam" : "dvalue"}; 57 | 58 | hb.when("GET", "/posts?defaultParam=dvalue&requestParam=rvalue").respond(200, []); 59 | 60 | wait(store.list("posts", params: {"requestParam" : "rvalue"})); 61 | }); 62 | }); 63 | 64 | describe("custom queries", () { 65 | it("returns one resource", (MockHttpBackend hb, ResourceStore store) { 66 | hb.whenGET("/posts/123").respond({"id": 123, "title" : "SampleTitle"}); 67 | 68 | wait(store.customQueryOne("posts", new CustomRequestParams(method: "GET", url:"/posts/123")), (resource) { 69 | expect(resource.content["title"]).toEqual("SampleTitle"); 70 | }); 71 | }); 72 | 73 | it("returns many resource", (MockHttpBackend hb, ResourceStore store) { 74 | hb.whenGET("/posts").respond([{"id": 123, "title" : "SampleTitle"}]); 75 | 76 | wait(store.customQueryList("posts", new CustomRequestParams(method: "GET", url: "/posts")), (resources) { 77 | expect(resources.length).toEqual(1); 78 | expect(resources[0].content["title"]).toEqual("SampleTitle"); 79 | }); 80 | }); 81 | }); 82 | }); 83 | 84 | 85 | describe("Commands", () { 86 | it("create a resource", (MockHttpBackend hb, ResourceStore store) { 87 | hb.expectPOST("/posts", '{"title":"New"}').respond({"id" : 123, "title" : "New"}); 88 | 89 | final post = resource("posts", null, {"title": "New"}); 90 | 91 | wait(store.create(post), (resp) { 92 | expect(resp.content["id"]).toEqual(123); 93 | expect(resp.content["title"]).toEqual("New"); 94 | }); 95 | }); 96 | 97 | it("updates a resource", (MockHttpBackend hb, ResourceStore store) { 98 | hb.expectPUT("/posts/123", '{"id":123,"title":"New"}').respond({"id": 123, "title": "Newer"}); 99 | 100 | final post = resource("posts", 123, {"id": 123, "title": "New"}); 101 | 102 | wait(store.update(post), (resp) { 103 | expect(resp.content["id"]).toEqual(123); 104 | expect(resp.content["title"]).toEqual("Newer"); 105 | }); 106 | }); 107 | 108 | it("updates a nested resource", (MockHttpBackend hb, ResourceStore store) { 109 | hb.expectPUT("/posts/123/comments/456", '{"id":456,"text":"New"}').respond({}); 110 | 111 | final post = resource("posts", 123); 112 | final comment = resource("comments", 456, {"id": 456, "text" : "New"}); 113 | 114 | wait(store.scope(post).update(comment)); 115 | }); 116 | 117 | it("deletes a resource", (MockHttpBackend hb, ResourceStore store) { 118 | hb.expectDELETE("/posts/123").respond("OK"); 119 | 120 | final post = resource("posts", 123); 121 | 122 | wait(store.delete(post), (resp) { 123 | expect(resp.content).toEqual("OK"); 124 | }); 125 | }); 126 | 127 | it("handles errors", (MockHttpBackend hb, ResourceStore store) { 128 | hb.expectDELETE("/posts/123").respond(500, "BOOM", {}); 129 | 130 | final post = resource("posts", 123); 131 | 132 | waitForError(store.delete(post), (resp) { 133 | expect(resp.content).toEqual("BOOM"); 134 | }); 135 | }); 136 | 137 | it("supports custom commands", (MockHttpBackend hb, ResourceStore store) { 138 | hb.expectDELETE("/posts/123").respond("OK"); 139 | 140 | final post = resource("posts", 123); 141 | 142 | wait(store.customCommand(post, new CustomRequestParams(method: 'DELETE', url: '/posts/123')), (resp) { 143 | expect(resp.content).toEqual("OK"); 144 | }); 145 | }); 146 | }); 147 | 148 | 149 | describe("Custom Configuration", () { 150 | it("uses route", (HammockConfig config, MockHttpBackend hb, ResourceStore store) { 151 | config.set({ 152 | "posts" : {"route": 'custom'} 153 | }); 154 | 155 | hb.whenGET("/custom/123").respond({}); 156 | 157 | wait(store.one("posts", 123)); 158 | }); 159 | 160 | it("uses urlRewriter", (HammockConfig config, MockHttpBackend hb, ResourceStore store) { 161 | config.urlRewriter.baseUrl = "/base"; 162 | config.urlRewriter.suffix = ".json"; 163 | 164 | hb.whenGET("/base/posts/123.json").respond({}); 165 | 166 | wait(store.one("posts", 123)); 167 | 168 | config.urlRewriter = (url) => "$url.custom"; 169 | 170 | hb.whenGET("/posts/123.custom").respond({}); 171 | 172 | wait(store.one("posts", 123)); 173 | }); 174 | }); 175 | }); 176 | } --------------------------------------------------------------------------------