webwire logo

Discord Chat

Webwire is a contract-first API system which features an interface description language, a network protocol and code generator for both servers and clients.

This repository contains the documentation sources used to generate the website at https://webwire.dev/.

Resources

Building blocks

Unique selling points

  • Webwire generates client and server code which is ready to run. The generated code contains everything to make requests and implement services.

  • Webwire supports both stateless unidirectional communication and and stateful bidirectional communication. This makes it a perfect fit for application that require some kind of real-time update from the server without the client having to poll for updates.

  • Webwire validates requests and responses. If data does not match the given schema an error is raised an the data is not processed any further.

  • Webwire is modelled after programming languages and not after a serialization format. Therefore types like UUID, Date and Time are part of the specification even if the used serialization format does not support them. When using a serialization format which does not support those types natively (e.g. JSON) they are encoded as string. This is transparent to the user of webwire.

  • Webwire has a special type called fieldset. Fieldsets can be used to construct a struct out of another struct by picking a subset of fields. This is especially useful when designing APIs where multiple endpoints use almost the same structure which just differs in a few fields.

Non goals

  • Webwire can not be used to describe existing APIs. Webwire only makes sense as a whole package. The IDL, protocol, code generator and libraries all make a complete package and leaving out one or the other just doesn't make any sense. If you need to document an existing API have a look at OpenAPI.

Example

The following example assumes a Rust server and a TypeScript client. Webwire is by no means limited to those two but those languages show the potential of webwire best.

Given the following IDL file:

webwire 1.0;

struct HelloRequest {
    name: String,
}

struct HelloResponse {
    message: String,
}

service Hello {
    hello: HelloRequest -> HelloResponse
}

The server and client files can be generated using the code generator:

$ webwire generate rust server api/hello.ww server/src/api.rs
$ webwire generate ts client api/hello.ww client/src/api.ts

A Rust server implementation for the given code would look like this:

use std::net::SocketAddr;
use webwire::{Context, Request, Response}
use webwire::hyper::Server;

mod api;
use api::v1::{Hello, HelloRequest, HelloResponse}; // this is the generated code

struct HelloService {}

impl Hello for HelloService {
    fn hello(&self, ctx: &Context, request: &HelloRequest) -> HelloResponse {
        HelloResponse {
            message: format!("Hello {}!", request.name)
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error> {
    let addr = SocketAddr::from(([127, 0, 0, 1], 8000));
    let service = HelloService {};
    let server = webwire::Server::bind(addr).serve(service);
    server.await
}

A TypeScript client using the generated code would look like that:

import { Client } from 'api/v1' // this is the generated code

client = new Client('http://localhost:8000/')
const response = await client.hello({ name: 'World' })
assert(response.message === 'Hello World!')

Code generator

This chapter is work in progress

Installation

The code generator can be installed using cargo:

cargo install webwire-cli

Interface Description Language

The interface description language of webwire is inspired by the Rust programming language.

The syntax is specified using Extended Backus-Naur Form (EBNF):

|   alternation
()  grouping
[]  option (zero or one time)
{}  repetition (any number of times)

Lexical elements

LETTER = "A" … "Z" | "a" ... "z"
DIGIT_DEC = "0" … "9"
DIGIT_HEX = DIGIT_DEC | "A" … "F" | "a" … "f"

Identifier

Identifiers must start with a letter. Subsequent characters may also include digits and the underscore "_" character.

identifier = LETTER { LETTER | DIGIT_DEC | "_" }

Values

Boolean

Booleans are either true or false.

boolean = "true" | "false"

Integer

Integers support both decimal and hexadecimal format.

integer_dec = [ "+" | "-" ] DIGIT_DEC { DIGIT_DEC }
integer_hex = [ "+" | "-" ] "0" ("X" | "x") DIGIT_HEX { DIGIT_HEX }
integer = integer_dec | integer_hex

Examples:

  • 57005
  • +3
  • -5
  • 0x539
  • +0xFF
  • -0x7FFF

Float

Floats must contain at least one digit before and after the decimal separator.

float = [ "+" | "-" ] DIGIT_DEC { DIGIT_DEC } "." DIGIT_DEC { DIGIT_DEC }

Examples:

  • 2.56
  • +5.3338
  • -0.5

String

Strings are quoted using double quotes '"' and the backspace character "\" is used to escape special characters.

Supported escapes are:

  • \\ → backspace "\"
  • \" → double quote '"'
  • \n → newline (linefeed)
string = '"' { char_escape | /[^\\]/ } '"'
char_escape = "\" ( "\" | '"' | "n" )

Examples:

  • "master"
  • "line1\nline2\nline3"
  • "backslash: \\"

Range

Ranges have an upper and lower bound which are separated via ".."

range = integer ".." integer

Examples:

  • 0..255
  • 0..0xFF
  • 1..
  • ..50

Types

Types can either be named or used as part of an array type or map type. The named form also supports passing generic parameters.

type = (type_named | type_array | type_map) [ type_options ]
type_named = identifier ["<" type { "," type } ">"]
type_array = "[" type "]"
type_map = "{" type ":" type "}"
type_options = "(" [ type_option { "," type_option } [ "," ] ] ")"
type_option = identifier "=" value

Examples:

  • FooBar
  • PaginatedResponse<Bar>
  • [ Integer ]
  • [ Integer ] (length=1..16)
  • { Integer: String }
  • String (length=0..50)
  • Integer (range=-0x80..0x7F)

Builtin types

Builtin types are reserved type names and can not be used as names for your own types. It is however possible to use them as identifiers though this SHOULD NOT be done on a regular basis.

Builtin types are:

  • Boolean
  • Integer
  • Float
  • String
  • Date
  • Time
  • DateTime
  • UUID
  • None
  • Nullable<T>
  • Result<T, E>

None is a special type that does only have one valid value which is None. This is useful for methods that don't have input or output types and when working with optional generics. None should not be used on its own for field types as it has no meaning there.

Nullable is a special type which wraps another type internally. It is similar to enum Nullable<T> { Some(T), Null } except that it maps to null or a similar values in programming languages that support this concept. It is typically used in APIs to clear values. This must not be confused with optional fields of structures.

Result is a special type which is an enum that can either be Ok(T) or Err(E). It is similar to enum Result<T, E> { Ok(T), Err(E) } except that it maps directly to the builtin Result type if supported by the target programming language.

Generics

Structures, Enumerations and some builtin types support generics.

generics = "<" identifier { "," identifier } ">"

Struct

Structures are a collection of fields of storing complex data structures.

struct = "struct" identifier [ generics ] "{" [ struct_fields ] "}"
struct_fields = struct_field "," struct_field
struct_field = identifier [ "?" ] ":" type

Examples:

  • struct Pet {
        name: String,
        age?: Integer
    }
    
  • struct Complex {
        r: Float,
        i: Float
    }
    
  • struct Person {
        first_name: String (length=1..50)
    }`
    
  • struct PaginatedResponse<T> {
        results: T,
        page: Integer(range=0..),
        count: Integer(range=0..),
    }
    

Optional fields and nullable types

By appending a ? to the field identifier a field is marked as optional. Optional means that the field can be absent from the structure.

There also exists the special Nullable<T> type which is used for something completely different. While optional means that the field can be missing from the serialized structure Nullable<T> means that instead of T a special Null value can be transferred instead. It is perfectly valid to use optional and nullable at the same time:

struct UpdateProfile {
    name?: String,
    age?: Nullable<Integer>,
}

Both name and age are optional in this example. Since Strings do have a special empty value (the empty string: "") it does not need to be Nullable. Integers on the other hand do not have such thing so Nullable<Integer> would make it possilbe to clear the age of a user profile. A JSON serialized structure containing an empty string for the name and Null for the age would look like this:

{
    "name": "",
    "age": null,
}

Some programming languages and serialization formats support this quite naturaly. JavaScript and JSON differenciate between undefined and null for object attributes. For languages that don't support this out of the box optional fields and nullable types are put in a special wrapper.

Fieldset

Fieldset is a special kind of structure that does not define its own fields but uses an existing structure and creates a subset of it. This feature is mainly for keeping the repetition for typical CRUD APIs as little as possible.

fieldset = "fieldset" identifier "for" identifier "{" struct_fields "}"
fieldset_fields = [ fieldset_field { "," fieldset_field } [ "," ] ]
fieldset_field = identifier [ "?" ]

Examples:

  • fieldset PersonUpdate for Person {
        id,
        first_name?,
        last_name?
    }
    

Enumerations

Enumerations in webwire fulfill two things. They can either be used as plain enumerations like in most programming languages. An optional type argument makes it possible to describe tagged unions.

This is especially handly for returning errors which might contain data which depends on the actual error code.

Enumerations can also extend existing enumerations.

enum = "enum" identifier [ generics ] [ enum_extends ] "{" enum_variants "}"
enum_extends = "extends" identifier [ generics ]
enum_variants = [ enum_variant { "," enum_variant } [ "," ] ]
enum_variant = identifier [ "(" type ")" ]

Examples:

  • enum Status {
        Enabled,
        Disabled,
        Error
    }
    
  • enum AuthError {
        Unauthenticated,
        PermissionDenied
    }
    
  • enum GetError extends AuthError {
        DoesNotExist
    }
    
  • enum Notification {
        UserJoined(User),
        UserLeft(User),
        Message(ChatMessage),
    }
    

Namespace

TODO

namespace = "namespace" identifier "{" namespace_parts "}"
namespace_parts = { namespace_part }
namespace_part = struct | fieldset | enum | namespace | service

Service

TODO

service = ["async" | "sync"] "service" identifier "{" methods "}"
methods = [ method { "," method } [ "," ] ]
method = identifier ":" ( type | "None" ) "->" ( type | "None" )

Transport Protocol

The webwire transport protocol exists in two variants.

  1. Stateless unidirectional (e.g. HTTP)
  2. Stateful bidirectional (e.g. WebSocket connections)

Both variants share some common terms and definitions which are explained on this page.

Framing

It is assumed that all protocols being used to transfer webwire messages already implement framing. Thus the webwire protocol does not encode its own frame length but leaves that to the underlying protocol.

If the underlying protocol does not implement framing a framing layer must be implemented first. This is to be defined in the transport layer specific documentation.

Fully qualified method names (FQMN)

Fully qualified method names are dot (.) separated identifiers. All identifiers must be ASCII only, start with a letter followed by any number of alphanumeric characters. The last two parts are called the service name and the method name. Any leading part is called the namespace.

Examples:

FQMNNamespaceServiceMethod
Example.hello-Examplehello
foo.Example.hellofooExamplehello
foo.bar.Example.hellofoo.barExamplehello

Invalid examples:

FQMNWhy is it invalid?
helloOnly one part. Service and method name are mandatory
hey.123testThe method name must not start with a number
123hey.testThe service name must not start with a number
123ns.hey.testThe namespace must not start with a number
Über.awesomeNon ASCII character

Error codes

CodeDescription
ServiceNotFoundThe requested service does not exist.
MethodNotFoundThe requested method does not exist.
ValidationErrorThe data could not be deserialized and validated.
InternalErrorSomething bad happened while processing the request.

Websocket protocol

All messages are encoded as a space separated list. The first part of a message is always a numeric message type followed by message specific fields.

Message id

Most messages require a message_id to be sent. Both server and client must implement it as a counter without gaps starting at 1. It is used to match match request and response messages together and provide reliable messaging even in case of a unexpected connection timeout.

Message types

The following message types are supported:

CodeMessage type
0Heartbeat
1Notification
2Request
3Response
4Error response
-1Disconnect

Heartbeat

The heartbeat is used by the client and server to acknowledge messages and keep the connection alive if there has been no traffic for a given amount of time. For transports that do not keep the connection open for an unlimited amount of time this is used for polling.

FieldTypeDescription
message_typeIntegerConstant 0
last_message_idIntegerThe last message id of the remote side that has been received.

Example:

0 42

Notification

Send a notification to the remote side and do not expect a response. This message type is especially useful to implement broadcasts from the server to the client where no response is expected. Implementations of webwire MUST NOT expect a response to a notification.

Fields:

FieldTypeDescription
message_typeIntegerConstant 1
message_idIntegerThe id of the message.
methodFQMNThe fully qualified method name of the method to be called.
dataBinaryThe data field captures the rest of the frame and is treated as binary.

Example:

1 43 example.hello peter

Request

Send a request to the remote side and expect a response.

FieldTypeDescription
message_typeIntegerConstant 2
message_idIntegerThe id of the message.
methodFQMNThe fully qualified method name of the method to be called.
dataBinaryThe data field captures the rest of the frame and is treated as binary.

Example:

2 44 example.get_version

Response

This message is sent in response to a request if the remote method could be called successfully.

FieldTypeDescription
message_typeIntegerConstant 3
message_idIntegerThe id of the message.
request_message_idIntegerThe id of the message that started the request.
dataBinaryThe data field captures the rest of the frame and is treated as binary.

Example:

3 7 44 "1.4.9"

Error response

This message is sent in response to a request if the remote side encountered an error while processing the request. Please note that this message type MUST NOT be used to encode application level errors. This is only meant to be used for errors which are outside of the application scope. e.g. parser errors, data validation, internal server errors, etc.

FieldTypeDescription
message_typeIntegerConstant 4
message_idIntegerThe id of the message.
request_message_idIntegerThe id of the message that started the request.
error_codeBinaryThe data field captures the rest of the frame and is treated as binary.
error_messageStringOptional error message.

Example:

4 7 44 MethodNotFound

Disconnect

Terminate the current connection. The remote side should respond with a -1 and close the connection.

FieldTypeDescription
message_typeIntegerConstant -1

Example:

-1

Example communication

Assuming the connection has been up for a while and the server has now reached message id 117. The client has sent 5 messages so far and the next message id is 5.

  1. Heartbeat (client to server):

    0 117
    
  2. Heartbeat (server to client):

    0 4
    
  3. Notification (client to server):

    1 5 Player.ready true
    
  4. Request (client to server):

    2 6 get_time
    
  5. Response (server to client):

    3 118 6 1342106240
    

HTTP

Path

Requests and notifications are sent relative to a given base_url with the fully qualified method name appended:

Example:

base_url: /ww
fqmn: Example.hello

path: /ww/Example.hello

Headers

The following headers are respected by webwire:

HeaderDescription
X-WebwireThis must be either Notification or Request

Notification

The server is expected to answer with 204 OK or 400 Bad Request. In case of an internal server error the status code 500 Internal Server Error is identical to a 400 Request with the InternalError payload.

HTTP request:

POST /ww/example HTTP/1.1
Host: example-api.webwire.dev
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
X-Webwire: Notification

"world"

HTTP response - Ok:

HTTP/1.1 204

HTTP response - Error:

HTTP/1.1 400

"MethodNotFound"

Request

The server is expected to answer with 200 OK or 400 Bad Request.

HTTP request:

POST /ww/Example.get_version? HTTP/1.1
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
X-Webwire: Request

HTTP response - Ok:

HTTP/1.1 200 OK

"1.12.3"

HTTP response - Error:

HTTP/1.1 400

"ValidationError"

Rust

This chapter is work in progress

Library

The Webwire library for Rust can be found here:

TypeScript (and JavaScript)

This chapter is work in progress

Library

The Webwire library for TypeScript can be found here: