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
- Website: https://webwire.dev/
- GitHub: https://github.com/webwire/
- Discord: https://discord.gg/jjD6aWG
Building blocks
-
The webwire interface description language describes service endpoints and how they are called.
-
The webwire protocol is the actual representation how data is transferred between client and server.
-
The webwire code generator uses the schema language to generate client and server stubs.
-
The webwire library is language specific and is used together with the code generated by the webwire code generator to write servers and clients using that interface.
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
andTime
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.
- Stateless unidirectional (e.g. HTTP)
- 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:
FQMN | Namespace | Service | Method |
---|---|---|---|
Example.hello | - | Example | hello |
foo.Example.hello | foo | Example | hello |
foo.bar.Example.hello | foo.bar | Example | hello |
Invalid examples:
FQMN | Why is it invalid? |
---|---|
hello | Only one part. Service and method name are mandatory |
hey.123test | The method name must not start with a number |
123hey.test | The service name must not start with a number |
123ns.hey.test | The namespace must not start with a number |
Über.awesome | Non ASCII character |
Error codes
Code | Description |
---|---|
ServiceNotFound | The requested service does not exist. |
MethodNotFound | The requested method does not exist. |
ValidationError | The data could not be deserialized and validated. |
InternalError | Something 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:
Code | Message type |
---|---|
0 | Heartbeat |
1 | Notification |
2 | Request |
3 | Response |
4 | Error response |
-1 | Disconnect |
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.
Field | Type | Description |
---|---|---|
message_type | Integer | Constant 0 |
last_message_id | Integer | The 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:
Field | Type | Description |
---|---|---|
message_type | Integer | Constant 1 |
message_id | Integer | The id of the message. |
method | FQMN | The fully qualified method name of the method to be called. |
data | Binary | The 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.
Field | Type | Description |
---|---|---|
message_type | Integer | Constant 2 |
message_id | Integer | The id of the message. |
method | FQMN | The fully qualified method name of the method to be called. |
data | Binary | The 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.
Field | Type | Description |
---|---|---|
message_type | Integer | Constant 3 |
message_id | Integer | The id of the message. |
request_message_id | Integer | The id of the message that started the request. |
data | Binary | The 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.
Field | Type | Description |
---|---|---|
message_type | Integer | Constant 4 |
message_id | Integer | The id of the message. |
request_message_id | Integer | The id of the message that started the request. |
error_code | Binary | The data field captures the rest of the frame and is treated as binary. |
error_message | String | Optional error message. |
Example:
4 7 44 MethodNotFound
Disconnect
Terminate the current connection. The remote side should respond with a -1
and close the connection.
Field | Type | Description |
---|---|---|
message_type | Integer | Constant -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.
-
Heartbeat (client to server):
0 117
-
Heartbeat (server to client):
0 4
-
Notification (client to server):
1 5 Player.ready true
-
Request (client to server):
2 6 get_time
-
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:
Header | Description |
---|---|
X-Webwire | This 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: