DEV Community

porta
porta

Posted on

I made an API toolkit

Cover image

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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, {});
Enter fullscreen mode Exit fullscreen mode

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 nulls 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}!`
    });
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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   |
       | <--------------- |
-------+                  +-------
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)