I'm discontinuing this project.
When I wrote this library, dart-lang/rpc
did not exist yet.
I think that their approach, with the
Discovery Document, is more future proof and
makes it easier for other developers to consume the API.
I personally like the idea of using Protocol Buffers
as a serialization format (which is the format I used in iris
), but JSON definitely makes it easier
to be used, and doesn't require third party developers to use a compiled client to communicate with the API.
There are two main advantages with Protocol Buffers:
- They handle serialization automatically (and check that all values are set properly)
- They are fast
The first advantage is handled by the RPC library itself, and the second one is not as important in a public API that has the overhead of HTTP requests and is mostly used on the internet (compared to the intranet).
A complete abstraction of client ↔ server communication.
It is basically a remote procedure call implementation in dart. You can call the methods on your remotes and get the result back in futures without having to think about the communication.
You can look at the example repository for an implementation.
The typical setup is as follows:
- Setup your server to generate protocol buffer messages
- Write your procedures that handle the requests.
- Create an iris object that group your remotes together and setup a server.
- Create a server binary which you can then execute to start your iris server.
- Setup the build.dart file to generate the client library.
- Use the library on the client
As you go along you will need more control over your configuration:
- Use error codes to tell the client what went wrong.
- Write a context initializer to add additional
information (eg: session information) to the
Context
received by filters and procedures. - Write filters for your remotes and procedures to reject requests on certain conditions (eg: authentication).
- Release the generated files as standalone library
Protocol buffers are a method of serializing structured data. They are fast and performant, developed and used by Google, and are a great way to define the data being passed between remotes (in contrast to JSON where you have to take care of validating the data yourself, and always need to look at the documentation to see what you actually receive).
The way they work in dart is: you define your messages in .proto
files and a
library converts them to dart
classes (subclasses of GeneratedMessage
) which
are typed and allow for auto completion and type checking.
Whenever a message in iris
is sent or received, it is an instance
of GeneratedMessage
.
Remotes basically are bundles of Procedures
. If you have a remote class
named RemoteUser
with a procedure (a method on this class, with the
Procedure
annotation) named create
, then you will be able to call this
remote procedure from the client with remoteUser.create()
.
Every procedure receives a Context
as first parameter and can accept a
GeneratedMessage
(protocol buffer message) as a second parameter.
The Context
contains basic request information (like cookies). If you want to
add additional information to the Context
object, see the
context initializers section.
This is a simple remote example:
class RemoteUser extends Remote {
/**
* This procedure has both, a return type ([CreateUserResponse]) and an
* expected request message ([CreateUserRequest]).
*/
@Procedure()
Future<CreateUserResponse> create(Context context, CreateUserRequest request) {
// Create the user, and return a CreateUserResponse
}
/**
* This procedure has no return type, so `iris` will assume that nothing will
* be sent back to the client. It will just await the execution.
*/
@Procedure()
Future delete(Context context, DeleteUserRequest request) {
// Delete the user, and return a resolved Future
}
/**
* This is an example procedure that receives and returns no message.
*/
@Procedure()
Future ping(Context context) => new Future.value();
}
As you can see, procedures can either accept and return GeneratedMessage
s or
not. Iris
understands this, and builds your client library
accordingly so you have proper auto completion when writing your client library.
In a separate file you create a function that returns an Iris
object. This
object will be used to start the server, and to build the files for the client.
Example lib/iris.dart
:
library remote_definitions;
import "package:iris/remote/iris.dart";
// This is the file that contains all your remotes
import "remotes/remotes.dart";
Iris getIris() {
return new Iris()
// Add the remotes you want to be served
..addRemote(new RemoteUser())
..addRemote(new RemoteAuthentication())
// Add the servers you want to use
..addServer(new HttpIrisServer("localhost", 8088, allowOrigins: const ['http://127.0.0.1:3030']));
}
To actually start the iris server which will listen on incoming connections,
you simply include Iris
and call .startServers()
on it.
Example bin/start_server.dart
:
import "../lib/iris.dart";
main() {
// Starts all servers that have been added with `.addServer()`.
getIris().startServers();
}
Now everything on your server is ready! The remotes are served automatically and are listening for incoming requests.
To use these remotes on the client, iris
generates a library
to be used on the client. This allows you to have completely typed classes that
you can use, with autocompletion and request / return types.
To let iris
build your client libraries, you need to edit your
build.dart
and add this build command:
library build;
import 'package:iris/builder/builder.dart' as iris_builder;
import "lib/iris.dart";
const IRIS_TARGET = "lib/client_remotes";
const IRIS_PROTO_BUFFER_MESSAGES = "lib/proto/messages.dart";
const IRIS_REMOTES_DIR = "lib/remotes/";
void main(List<String> args) {
iris_builder.build(getIris(), IRIS_TARGET, IRIS_PROTO_BUFFER_MESSAGES, args: args, includePbMessages: true, remotesDirectory: IRIS_REMOTES_DIR);
}
The builder will now rebuild your client library every time either your protocol
buffer messages or your remotes (only if you specify remotesDirectory
) change.
See the standalone library section for more information
on how to setup your build.dart
file to create a standalone library that can
be distributed separately.
Iris
provides two types of client libraries: one is meant to be
used on a server, and one for the browser.
Here's an example of using the remotes in a browser:
import "package:iris/client/browser_http_client.dart";
// This includes your generated library
import "package:my-generated-lib/remotes.dart";
main() {
var client = new HttpIrisClient(Uri.parse("http://localhost:8088"));
// Create an instance of your remotes
var remotes = new Remotes(client);
// And you're good to go!
AuthenticationRequest req = new AuthenticationRequest()
..email = "e@mail.com"
..password = "password";
remotes.remoteUser.auth(req).then((User user) => doSomething(user));
}
If an error occurs anywhere in a remote request you always
get an IrisException
on the client. This IrisException
has an
errorCode
and an internalMessage
.
Never show the
internalMessage
to the user! It is only meant to be logged or inspected by developers.
errorCode
s are all you need to tell the client what's wrong. Every time you
encounter a problem in your remote, think about what you want to tell the client
and create an error code for it.
This is how you setup error codes on the server:
class ErrorCode extends IrisErrorCode {
static const INVALID_USERNAME_OR_PASSWORD = const ErrorCode._(0);
static const INVALID_EMAIL = const ErrorCode._(1);
const ErrorCode._(int value) : super(value);
}
and this is how you would throw an error code in a procedure:
class RemoteUser extends Remote {
@Procedure()
Future create(MyContext context) {
throw new ProcedureException(ErrorCode.INVALID_EMAIL, "Oh noes.");
}
}
on your client:
remotes.remoteUser.create().then(print)
.catchError((IrisException ex) {
if (ex.errorCode == ErrorCode.INVALID_EMAIL) {
alert("Please provide a valid email address");
}
log.info(ex.internalMessage);
});
There are several internal error codes that you can receive on the client as
well. Look at the IrisErrorCode
class to see what they are.
If you provide this
ErrorCode
class to thebuild
function of the builder, anerror_code.dart
file is generated, containing all error codes as integers to be used on the client.
Every procedure and procedure filter receives a Context
object that gets
instantiated for every request. If you don't define a ContextInitializer
yourself, you will always receive the default Context
implementation, which
only holds the IrisRequest
object.
If you want to have additional information in you context (like session data),
you can define your own context class and provide a ContextInitializer
to
create that object for you.
ContextInitializer
s are the first thing called when a request comes in. After that all filters are called sequentially, and then your procedure with the initializedContext
.
This is the typedef
for ContextInitializer
s:
typedef Future<Context> ContextInitializer(IrisRequest req);
and here an example implementation:
/**
* Your own `Context` class
*/
class MyContext extends Context {
/// An additional field in your context to hold the session information.
final Session session;
MyContext(IrisRequest req, this.session) : super(req);
}
/**
* Now define your context initializer
*/
Future<MyContext> myContextInitializer(IrisRequest req) {
// This can do anything needed for context initialization. Example:
// Load session info from the memory cache
myMemoryCache.loadSession(req.cookies["sessionId"])
.then((Session session) {
// And return your context, *with* a session
return new MyContext(req, session);
})
}
Iris getIris() {
// And where you create you remote definitions, you now pass the context
// initializer
return new Iris(myContextInitializer)
..addRemote(RemoteUser)
..etc...
}
So, every time you receive a Context
object, it is now a MyContext
instance.
Often you need your procedures to be filtered, for example if you need authentication.
Filters are defined with the Remote
or the Procedure
annotation and this is
their typedef
:
typedef Future<bool> FilterFunction(Context context);
You can define filters in your remote like this:
Future<bool> authenticationFilter(Context context) {
// Make sure the user is authenticated.
return new Future.value(true);
}
Future<bool> adminRightsFilter(Context context) {
// Make sure the user has admin rights
return new Future.value(true);
}
/// All procedures in this remote will have the `authenticationFilter`.
@Remote(filters: const [authenticationFilter])
class RemoteUser extends Remote {
/// In addition to the `authenticationFilter` this procedure also has the
/// `adminRightsFilter`.
@Procedure(filters: const [adminRightsFilter])
Future<CreateUserResponse> create(Context context, CreateUserRequest request) => new Future.value();
}
If a filter returns false
, the procedure will not be called, and an error
will be sent to the client. If you want the client to receive a specific error
code, then you can use the ProcedureException
for that.
After the
ContextInitializer
function, all defined filters will be called sequentially and in the defined order and processing the request is immediately stopped when one filter returnsfalse
.
Remote filters are always the first filters to run.
If you have set a ContextInitializer
all filter functions will receive the
context returned by this function.
There are two ways you can distribute your remote remotes:
- As part of your server library
- As a separate, standalone library
Releasing the remotes as part of your library is easier. You can just
let the build script create the necessary client files in your lib/
directory,
and users can use your server as a dependency, and import the generated iris
files. This means that the user has access to your protocol buffer and
ErrorCode
files (since they are already in your server library).
The disadvantage of this approach is, of course, that your whole server needs to be exposed. This is fine if your library is only used internally (since you can have a dependency on a private repository), but if you want to distribute the generated client library to other users this won't be working anymore.
This is why iris
has the ability to include all necessary resources
in the generated library so it can be shipped as a separate library, namely:
- All protocol buffer messages
- The error codes
When invoking the build
function of the builder, you can additionally pass
the ErrorCode
class with the errorCodes
parameter. Iris
will
then generate a error_code.dart
file with an ErrorCode
class that contains
all error codes.
If you set the includePbMessages
option to true
, iris
will also
copy over all protocol buffer messages, and put them in the proto/
folder.
With the targetDirectory
argument (the second positional argument), you can
define a directory outside your server directory, which is the library that
you can ship without having to worry about leaking sensitive code.
(The MIT License)
Copyright (c) 2014 Matias Meno <m@tias.me>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.