concord transforms Typescript interfaces into usable client / server code.
It simplifies the process of writing clients and servers and lets you concord your code faster.
Instead of describing REST APIs, concord
abstracts away REST and HTTP and gives you a simple typescript interface.
Behind the scenes it uses simple HTTP POST with JSON payload and is validated using JSONSchema. The heavy lifting is done by typescript-json-schema.
-
Create the interface file.
interface.ts
export interface Example { add: { params: { a: number; b: number; }; returns: number; }; }
-
Compile the schema.
concord -o ./generated node --client fetch --server koa example@0.0.1 interface.ts
-
Write the server code.
server.ts
import { ExampleServer } from './generated/server'; class Handler { public async add(a: number, b: number): Promise<number> { return a + b; } } const h = new Handler(); const server = new ExampleServer(h); server.listen(8080);
-
Write the client code.
client.ts
import { ExampleClient } from './generated/client'; async function main() { const client = new ExampleClient('http://localhost:8080'); try { const x = await client.add(1, 2); console.log(x); } catch (err) { console.error(err); } } main();
-
Run (make sure
tsconfig.json
is properly configured for node and is present in the current directory) TODO: Test this processtsc ./server.js & ./client.js
Alternatively with
ts-node
:ts-node ./server.ts & ts-node ./client.ts
Concord can create an npm package for you and publish it if instead of specifying an output dir you give it a publish target.
In the following example concord
will publish the generated server files to npm as example-client@0.0.1:
concord node --publish --client fetch example-client@0.0.1 interface.ts
The first positional argument given to the concord
CLI is runtime
, currently supported runtimes are node
and browser
.
The node node
runtime supports koa
and binaris servers and a fetch
client.
Run concord <runtime> --help
for more details.
curl -X POST -H 'Content-Type: application/json' http://serverAddress:serverPort/add -d '{"a": 1, "b": 2}'
http post http://server-address/add a:=1, b:=2
Complex nested object types are supported.
Date
parameter types or return type are validated by JSON schema and cast into back to Date
objects when deserialized.
export interface User {
name: string;
createdAt: Date;
}
export interface Example {
lastSeen: {
params: {
u: User;
};
returns: Date;
};
}
Some use cases require context to be passed on to handlers (i.e. for authentication / extracting the request IP).
There are 2 types of contexts in concord
, ClientContext
and ServerOnlyContext
.
ClientContext
is prepended to the client call signature and is exported asContext
from the generated client file.ServerOnlyContext
is extracted by the server using a custom provided function that accepts a request object (depends on the runtime) and returns a context object. Handler methods receive a context which is an intersection ofClientContext
andServerOnlyContext
and is exported asContext
from the generated server code.
To use contexts simply add them to your interfaces file.
interface.ts
export interface ClientContext {
token: string;
}
export interface ServerOnlyContext {
ip: string;
}
export interface Example {
hello: {
params: {
name: string;
};
returns: integer;
};
}
server.ts
import * as koa from 'koa';
import { ExampleServer, Context, ServerOnlyContext } from './generated/server';
export class Handler {
public async extractContext({ ip }: koa.Context): Promise<ServerOnlyContext> {
return { ip };
}
public async hello({ token, ip }: Context, name: string): Promise<string> {
return `Hello, ${name} from ${ip}, token: ${token}`;
}
}
const h = new Handler();
const server = new ExampleServer(h);
server.listen(8080);
client.ts
import { ExampleClient, Context } from './generated/client';
async function main() {
const client = new ExampleClient('http://localhost:8080');
await client.hello({ token: 'abc' }, 'baba'); // Hello, baba from 127.0.0.1, token: abc
}
main();
When exporting multiple interfaces from the same file you might want to use custom Context for each interface.
In order to use the custom Context interfaces specify clientContext
or serverOnlyContext
fields on your interface, like so:
export interface AuthInfo {
token: string;
}
export interface User {
name: string;
}
export interface Example {
clientContext: AuthInfo;
serverOnlyContext: User;
greet: {
params: {
greeting: string;
};
returns: string;
}
}
In order to disable a context on your interface, specify: clientContext: false
or serverOnlyContext: false
.
concord -o ./generated_client node --client fetch example-client@0.0.1 interfaces.ts
concord -o ./generated_server node --server koa example-server@0.0.1 interfaces.ts
server.ts
// ...
import { ExampleRouter } from './generated/server';
// ... implement Handler class ...
const h = new Handler();
const router = new ExampleRouter(h);
const app = new Koa();
const baseRouter = new Router(); // koa-router
baseRouter.use('/prefix', router.koaRouter.routes(), router.koaRouter.allowedMethods());
app.use(baseRouter.routes());
app.use(async function myCustomMiddleware(ctx: koa.Context, next) {
// ... implement middlware ...
});
// ... app.listen(), etc ...
Use annotations to specify JSON Schema attributes.
export interface Example {
add: {
params: {
/**
* @minimum 0
*/
a: integer;
/**
* @minimum 0
*/
b: integer;
};
returns: integer;
};
}
Define integer
as number
, it'll be reflected in the generated JSON schema while the generated Typescript code will be typed as number
.
export type integer = number;
export interface Example {
add: {
params: {
a: integer;
b: integer;
};
returns: integer;
};
}
When null is the only return type on a method, as in returns: null
, it will compile to Promise<void>
.
Defining returns: null | SomethingElse
on a method will compile to Promise<null | SomethingElse>
return type.
-
Create a new directory.
-
Switch to new directory.
-
Create an
interface.ts
file as shown above. -
Generate your server code with
concord node --client fetch --server binaris example@0.0.1 interface.ts -o generated
(the client is here for the sake of the example and is not required). -
Run
bn create node8 add
. -
Implement a handler:
function.js
const { ExampleWrapper } = require('./generated/server'); exports.handler = ExampleWrapper.add(async (a, b) => a + b);
-
Export your binaris credentials:
export BINARIS_ACCOUNT_ID=$(bn show accountId) BINARIS_API_KEY=$(bn show apiKey)
-
Deploy your function with
bn deploy add
. -
Create a test file
test.js
const { ExampleClient } = require('./generated/client'); async function main() { const url = `https://run.binaris.com/v2/run/${process.env.BINARIS_ACCOUNT_ID}`; const client = new ExampleClient(url, { headers: { 'X-Binaris-Api-Key': process.env.BINARIS_API_KEY, }, }); console.log('1 + 2 =', await client.add(1, 2)); } main();
-
Run your test with
node test.js
, should print out1 + 2 = 3
(That is if I can do math ;) ).
OpenAPI provides an easy way to write descriptive REST APIs.
concord
on the other hand, spares you from even thinking about REST and lets you focus on your buisness logic.
Both projects use JSON Schema for input validation. OpenAPI let's you write pure JSON schema while concord
interfaces are written in typescript.
protobuf
and thrift
have ATM more efficient serialization.
They both enforce backwards compatibility better with field numbering? (TODO)
In the future we could add binary serialization to concord
but we default to JSON for readability.
concord
provides advanced type validation with JSON schema.
concord
uses mustache templates which are easy to customize to support any language / framework.