In this article I would like to introduce SpeedAPI - a project of my own. It's essentially an API toolkit that supports a lot of features that, admittedly, GraphQL also supports, but does that with an extremely small overhead thanks to a custom binary encoding. This project used to be called AMOGUS before the 1.3 release which came out today (not too proud of that name.) Anyways, let's go!
The Basics
SpeedAPI uses a separate language called SUS for its schema. Take a look:
globalmethod say_hi(0) {
name: Str;
returns {
greeting: Str;
}
}
This description of the say_hi
should be quite self-explanatory and almost even feel natural... apart from one thing: what is that zero doing in those parentheses? That's the value - a number that distinguishes this method from other methods in this scope. SpeedAPI doesn't use its name to identify it when exchanging data over the network because that'd be wasteful - remember, our main goal here (along with developer satisfaction) is absolute maximum possible encoding efficiency.
Okay, now that we have this schema we need to compile it. In this case I'm going to be using TypeScript because that's the only language supported by the platform at the moment, though it is very extensible. To do that, I'm going to first install and then invoke the compiler like this:
$ pip3 install susc
$ susc -l ts api.sus
Oh cool! I now have an api_output
folder next to my source file. Inside it I see a ts
folder and index.ts
inside that folder. This file is my API definition, but translated to TypeScript in a format that the @speedapi/driver
npm library understands.
To make use of this data, let's first import that library and the compiled definition:
import * as speedapi from "@speedapi/driver";
import * as api from "./api_output/ts/index";
And point the library to our definition by creating a dummy client and server that exchange data by passing binary data to each other in memory:
type ApiType = ReturnType<typeof api.$specSpace>;
const { client, server } = createDummyPair<ApiType>(api.$specSpace);
Let's also bind our client
and server
(plain sessions) to the API:
const clientSession = api.$bind(client);
const serverSession = api.$bind(server);
const serverHandler = new Server(server, {});
Great! Those two things can communicate now. What's most important here is that with a static schema not only do we get extremely efficient data representation, but because we're using TypeScript we also get typing guarantees, meaning those two things can only strictly communicate in predefined ways. No unexpected null
s and type mismatches!
Let's write the server-side handler:
serverHandler.onInvocation("say_hi", async (method, _state) => {
await method.return({
greeting: `Hello, ${method.params.name}!`
});
});
As an exercise, try changing the value of greeting
from that template literal to some number or object literal and observe how your IDE puts a red squiggle below this line.
Now let's write the client:
// not using 'await' because this is the top level
clientSession.sayHi({ name: "reader" }).then(({ greeting }) =>
console.log(greeting));
(I once again encourage you to change the string literal to something else and note the typing)
If you then run this file (with node
after first compiling it with tsc
or by simply using ts-node
), you should see Hello, reader!
printed to console. What a fancy way to print a static string, huh?
Under the hood
A lot of things were done to print that innocent string. First, the client initiated a transaction by sending the server a MethodInvocation segment. That segment contained the method number and its scope (global), along with its only argument - name
, a string. This is exactly the bytes that it sent in hex:
00 00 00 00 06 72 65 61 64 65 72
| | | \---/ \---------------/
| | | | string data
| | | |
| | | +- string length
| | |
| | +- method number and scope (global)
| |
| +- prefix byte:
| t=0: MethodInvocation segment
| O=0: no optional fields
|
+- transaction ID
Note the low overhead - we have 5 bytes describing the request and 6 bytes of actual data. Compare this to equivalent JSON data: {"method":"say_hi","name":"reader"}
, where the metadata is 5x larger than the payload.
The server read that segment, ran our callback and ended the transaction by responding with a MethodReturn
segment:
00 00 00 0E 48 65 6c 6c 6f 2c 20 72 ...
| | \---/ \-------------------------/
| | | string data
| | |
| | +- string length (14)
| |
| +- prefix byte:
| t=0: MethodReturn segment
| O=0: no optional fields
|
+- transaction ID
The data stream is split into segments that can be interlaced, meaning the client could've sent another MethodInvocation
with another transaction ID without waiting for the return of this one.
SpeedAPI also supports confirmations - a special request the server makes to a client while the latter is in the process of invoking a method.
-------+ do_thing() +-------
| ---------------> |
client | | server
| confirmation |
| <--------------- |
| |
| response |
| ---------------> |
| |
| method return |
| <--------------- |
-------+ +-------
The server can provide data in its request, and the client can return some data in its response. For example, if you need CAPTCHAs, you can send an image URL in the request and expect a solution back. All of that conforming to the schema, of course :)
Server-initiated transactions
For this we need to talk about entities. They're like objects in OOP languages: a piece of data with some actions associated with it.
entity User(0) {
id: Int(8); # 8-byte / 64-bit integer
name: Str;
bio: Str;
# element type, index size (1 byte / 8 bit)
loved_topics: List(Str, 1);
# this method is dynamic, meaning it's attached
# to a particular instance of an entity, identified
# by the id field
method report(0) {
reason: Str;
returns { }
}
# this method is static, meaning it's not attached
# to any particular instance of the entity, but it
# _is_ within the scope of this entity
staticmethod create(0) {
name: Str;
returns {
id: Int(8);
}
}
}
The compiler has also sneaked in two methods for us: a dynamic update
and a static get
, which update and get the server-stored entity respectively.
SpeedAPI allows the server to unconditionally (outside of a method call context) send entities or entity updates to its clients. Internally this is implemented with a EntityUpdate
segment and a transaction that is initiated and then immediately closed by the segment.
New features in 1.3
Partial List Updates
As the name implies, a party (the server or the client) can update parts of a list belonging to an entity instead of re-sending the full list again. You can either append
, prepend
, insert
or remove
a certain number of elements to/into/from the list.
Plain list representations are compatible with previous protocol versions as long as PLUs are not used and list lengths are kept to 15/16ths (approx. 93%) of their max capacity.
Cache
The client can now use the Cache
class that caches Entity get
requests, listens to entity updates (automagically merging PLUs with already cached data too) and notifies its subscribers of updates.
Non-Int(8)
entity ids
Before 1.3, the only valid type for the id
field of an entity was Int(8)
. In 1.3, this field can now be of any type.
Compatibility with previous protocol versions is not broken as long as Int(8)
is used for the id field, or any other type that serializes to 8 bytes, e.g. a 6-char string.
Further reading
This article has only scratched the surface of what's possible with this tool. There's an extremely detailed tutorial here.
Thank you for reading this article and cheers!
Top comments (0)