Skip to content

Instantly share code, notes, and snippets.

@vsavkin
Last active August 29, 2015 14:02
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save vsavkin/29b1b7a44cb47f322734 to your computer and use it in GitHub Desktop.
Save vsavkin/29b1b7a44cb47f322734 to your computer and use it in GitHub Desktop.
AngularDart REST Client

Hammock

I released a library based on this spike. You you can find it here: https://github.com/vsavkin/hammock.

SPIKE: AngularDart REST Client

Every client-side applications has to talk to REST APIs. At the moment AngularDart does not provide any high-level abstractions to help you do that. You can send http requests, but that's it.

This post is about a spike I did a few days ago to explore possible ways of building such a library. It also shows that you can do quite a bit in just one hundred lines of Dart.

Design Principles

Plain old Dart objects. No active record.

Angular is different from other client-side frameworks. It lets you use simple framework-agnostic objects for your components, controllers, formatters, etc.

In my opinion 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 you wish so.

This means that:

post.save()
post.delete()

are not allowed.

Convention over Configuration

Everything should work with the minimum amount of configuration, but, if needed, be extensible. It should be possible to configure how data is serialized, deserialized, etc.

Now, let's look at some code...

There are three main components: Resource, ResourceStore, and ObjectStore.

Resource

We can create a Resource object using the res(type, id, [content]) function:

res("posts", 1, {"title": "some post"})

ResourceStore

We can create, update, and load resources from the server using ResourceStore.

We can get one entity if we know its type and id.

it("fetches a resource", inject((MockHttpBackend hb, ResourceStore store) {
  hb.when("/some/123").respond('{"id": 123, "field" : "value"}');

  waitForHttp(store.one("some", 123), (Resource res) {
    expect(res.id).toEqual(123);
    expect(res.content["field"]).toEqual("value");
  });
}));

By default, the resource type is used to construct the url, but we can change it by configuring the store:

beforeEach(inject((ResourceStore store) {
  store.config = {
      "some" : {"route": "secret"}
  };
}));

it("uses the configuration", inject((MockHttpBackend hb, ResourceStore store) {
  hb.when("/secret/123").respond('{"id": 123}');

  waitForHttp(store.one("some", 123), (Resource res) {
    expect(res.id).toEqual(123);
  });
}));

One and List

Suppose we have our store configured as follows:

beforeEach(inject((ResourceStore store) {
  store.config = {
      "posts" : {"route": 'posts'},
      "comments": {"route": "comments"}
  };
}));

We can load one or many posts:

it("returns a post", inject((MockHttpBackend hb, ResourceStore store) {
  hb.when("/posts/123").respond('{"id": 123, "title" : "SampleTitle"}');

  waitForHttp(store.one("posts", 123), (Resource post) {
    expect(post.id).toEqual(123);
    expect(post.content["title"]).toEqual("SampleTitle");
  });
}));

it("returns many posts", inject((MockHttpBackend hb, ResourceStore store) {
  hb.when("/posts").respond('[{"id": 123, "title" : "SampleTitle"}]');

  waitForHttp(store.list("posts"), (List<Resource> posts) {
    expect(posts.length).toEqual(1);
    expect(posts[0].content["title"]).toEqual("SampleTitle");
  });
}));

Nested Resources

We can also load nested resources:

it("returns a comment", inject((MockHttpBackend hb, ResourceStore store) {
  hb.when("/posts/123/comments/456").respond('{"id": 456, "text" : "SampleComment"}');

  waitForHttp(store.scope(res("posts", 123)).one("comments", 456), (Resource comment) {
    expect(comment.id).toEqual(456);
    expect(comment.content["text"]).toEqual("SampleComment");
  });
}));

To do that we had to scope our store using another resource:

ResourceStore post123Store = store.scope(res("posts", 123))

We can do it as many times as we want:

store.scope(res("blogs", 777)).scope(res("posts", 123));

Updates

A resource can be saved as follows:

it("saves a post", inject((MockHttpBackend hb, ResourceStore store) {
  hb.expectPUT("/posts/123", '{"id":123,"title":"New"}').respond(['OK']);

  final post = res("posts", 123, {"id": 123, "title": "New"});
  waitForHttp(store.save(post));
}));

And, as you have probably guessed, saving a comment involves scoping:

it("saves a comment", inject((MockHttpBackend hb, ResourceStore store) {
  hb.expectPUT("/posts/123/comments/456", '{"id":456,"text":"New"}').respond(['OK']);

  final post = res("posts", 123);
  final comment = res("comments", 456, {"id": 456, "text" : "New"});

  waitForHttp(store.scope(post).save(comment));
}));

ObjectStore

That's all well and good, but a bit too low-level. That is why there are other abstractions that are built on top of ResourceStore. One of them is ObjectStore.

Suppose we have these classed defined:

class Post {
  int id;
  String title;
}

class Comment {
  int id;
  String text;
}

We want to be able to work with Posts and Comments, not with Maps. To do that we need to configure our store:

beforeEach(inject((ResourceStore store) {
  store.config = {
      Post : {
          "route": 'posts',
          "deserialize" : deserializePost,
          "serialize" : serializePost
      },
      Comment : {
          "route": "comments",
          "deserialize" : deserializeComment,
          "serialize" : serializeComment
      }
  };
}));

Where the serialization and deserialization functions are responsible for converting domain objects from/into resources.

Post deserializePost(Resource r) => new Post()
  ..id = r.id
  ..title = r.content["title"];

Resource serializePost(Post post) => 
    res(Post, post.id, {"id" : post.id, "title" : post.title});

Comment deserializeComment(Resource r) => new Comment()
  ..id = r.id
  ..text = r.content["text"];

Resource serializeComment(Comment comment) => 
    res(Comment, comment.id, {"id" : comment.id, "text" : comment.text});

Using Post and Comment

Having this config in place, we can load and save Posts and Comments. We do not have to work with Resource at all.

it("returns a post", inject((MockHttpBackend hb, ObjectStore store) {
  hb.when("/posts/123").respond('{"id": 123, "title" : "SampleTitle"}');

  waitForHttp(store.one(Post, 123), (Post post) {
    expect(post.title).toEqual("SampleTitle");
  });
}));

it("returns many posts", inject((MockHttpBackend hb, ObjectStore store) {
  hb.when("/posts").respond('[{"id": 123, "title" : "SampleTitle"}]');

  waitForHttp(store.list(Post), (List<Post> posts) {
    expect(posts.length).toEqual(1);
    expect(posts[0].title).toEqual("SampleTitle");
  });
}));

it("returns a comment", inject((MockHttpBackend hb, ObjectStore store) {
  final post = new Post()..id = 123;
  hb.when("/posts/123/comments/456").respond('{"id": 123, "text" : "SampleComment"}');

  waitForHttp(store.scope(post).one(Comment, 456), (Comment comment) {
    expect(comment.text).toEqual("SampleComment");
  });
}));

Updates

it("saves a post", inject((MockHttpBackend hb, ObjectStore store) {
  hb.expectPUT("/posts/123", '{"id":123,"title":"New"}').respond(['OK']);

  final post = new Post()..id = 123..title = "New";

  waitForHttp(store.save(post));
}));

it("saves a comment", inject((MockHttpBackend hb, ObjectStore store) {
  hb.expectPUT("/posts/123/comments/456", '{"id":456,"text":"New"}').respond(['OK']);

  final post = new Post()..id = 123;
  final comment = new Comment()..id = 456..text = "New";

  waitForHttp(store.scope(post).save(comment));
}));

Serializers and Deserializers

We do not have to define custom serializers and use reflection instead.

beforeEach(inject((ResourceStore store) {
  store.config = {
      Post : {
          "route": 'posts',
          "deserialize" : new MirrorBasedDeserializer(["id", "title"]),
          "serialize" : new MirrorBasedSerializer(["id", "title"])
      }
  };
}));

it("returns a post", inject((MockHttpBackend hb, ObjectStore store) {
  hb.when("/posts/123").respond('{"id": 123, "title" : "SampleTitle"}');

  waitForHttp(store.one(Post, 123), (Post post) {
    expect(post.title).toEqual("SampleTitle");
  });
}));

it("saves a post", inject((MockHttpBackend hb, ObjectStore store) {
  hb.expectPUT("/posts/123", '{"id":123,"title":"New"}').respond(['OK']);

  final post = new Post()..id = 123..title = "New";

  waitForHttp(store.save(post));
}));

SimpleObjectStore

When given an object, ObjectStore determines its resource type based on its runtime type. That is OK for the situations when there is one-to-one correspondence between domain objects and resources. It is not always the case, however. If you need more flexibility use SimpleObjectStore.

it("returns a comment", inject((MockHttpBackend hb, SimpleObjectStore store) {
  hb.when("/posts/123/comments/456").respond('{"id":123, "text" : "SampleComment"}');

  waitForHttp(store.scope('posts', 123).one("coment", 456), (Map comment) {
    expect(comment["text"]).toEqual("SampleComment");
  });
}));

it("saves a comment", inject((MockHttpBackend hb, SimpleObjectStore store) {
  hb.expectPUT("/posts/123/comments/456", '{"id":456,"text":"New"}').respond(['OK']);

  final comment = {"id" : 456, "text": 'New'};

  waitForHttp(store.scope('posts', 123).save("comments", 456, comment));
}));

Just a Spike

This is just a spike. So it covers only a small subset of the features you would expect from such a library.

Query API

The only queries that are supported right now are "find by id" and "find all". Obviously, that is not enough.

Associations

Modelling associations is a big one. One way to do that is be able to declare them when configuring the store.

beforeEach(inject((ResourceStore store) {
  store.config = {
      Post : {
          "route": 'posts',
          "hasMany": {"comments" : Comment},
          "deserialize" : deserializePost,
          "serialize" : serializePost
      },
      Comment : {
          "route": "comments",
          "deserialize" : deserializeComment,
          "serialize" : serializeComment
      }
  };
}));

Then, you should be able to include/exclude them when fetching/saving objects:

Future<Post> postWithComments = store.one(Post, 123);
Future<Post> postWithoutComments = store.one(Post, 123, exclude: ['comments']);

Modelling associations is extremely difficult, and, as a result, may be pushed back till later.

Identity Map

Implement an identity map.

jsonapi.org

Experiment with conforming to the jsonapi.org format.

Dirty Checking

It would be interesting to look into ways of using the Angular dirty checking to determine what should be sent to the server.

Serializers

Such things as MirrorBasedSerializer should be extracted into a separate package (maybe there is an existing package that can be used). As Victor Berchet pointed out, the smoke package can be used there.

Source Code + Tests

It is just a spike, and it is about 100 lines of Dart code (+ tests). You can check it out here.

Not a Spike...

I'm going to build a production version of this library soon. If you have any comments or suggestions, please, message me on twitter @victorsavkin or leave a comment down below.

@adam-singer
Copy link

Great job!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment