aisil 0.3.0

A lightweight framework to define APIs as types
Documentation

aisil

Lightweight framework to define APIs as types.

aisil is designed to be transport and protocol agnostic. At the moment, however, only one transport protocol is supported (HTTP's POST /<method_name> with json bodies). Feel free to extend the base framework with whatever fits your requirements.

See docs at docs.rs/aisil.

Define API

  • A method is defined as Request → (method name, Response) dependency in the context of an API (See HasMethod trait).

  • An API is defined as ApiMetaType[*] dependency (See IsApi trait), where [*] is a heterogeneous list of request types that belong to the API.

An example of an API definition with two methods:

/// Get A
#[derive(Serialize, Deserialize, JsonSchema, TS, DocumentedOpt)]
pub struct GetA;

#[derive(Serialize, Deserialize, JsonSchema, TS)]
pub struct PostA(pub bool);

/// Some example api
#[derive(DocumentedOpt)]
pub struct SomeAPI;

define_api! { SomeAPI => {
  // <method_name>, <RequestType> => <ResponseType>;

  // documentation for this method will be taken from DocumentedOpt
  get_a, GetA => bool;

  /// Post A
  post_a, PostA => Result<(), String>;
} }

Implement service

#[derive(Clone, Default)]
struct SomeBackend {
  a: Arc<Mutex<bool>>,
}

impl ImplsMethod<SomeAPI, GetA> for SomeBackend {
  async fn call_api(&self, _: GetA) -> bool {
    self.a.lock().await.clone()
  }
}

impl ImplsMethod<SomeAPI, PostA> for SomeBackend {
  async fn call_api(&self, PostA(new_a): PostA) -> Result<(), String> {
    let mut a = self.a.lock().await;
    (!*a).then_some(()).ok_or("can't post `a` anymore".to_owned())?;
    *a = new_a;
    Ok(())
  }
}

Expose service

As HTTP POST /<method_name>:

pub fn router() -> axum::Router {
  let backend = SomeBackend::default();
  aisil::post_json::mk_post_json_router::<SomeAPI, SomeBackend>().with_state(backend)
}

or as JsonRPC:

let backend = SomeBackend::default();
Router::new().route(
  "/rpc",
  post(async move |State(svc), Json(request): Json<server::JsonRpcRequest>| {
    Json(aisil::server::json_rpc::json_rpc_router::<SomeAPI, SomeBackend>(&svc, request).await)
  }),
).with_state(state);

Make client calls

Use that API to make type safe client calls:

Either HTTP POST /<method_name>:

let client = PostJsonClient::new(Url::parse(client_url)?, reqwest::Client::new());
client.call_api(PostA(true)).await?.unwrap();
let new_a = client.call_api(GetA).await?;
assert_eq!(new_a, true);

or as JsonRPC:

let client = JsonRpcClient::new(Method::POST, Url::parse(client_url)?, reqwest::Client::new());
client.call_api(PostA(true)).await?.unwrap();
let new_a = client.call_api(GetA).await?;
assert_eq!(new_a, true);

Generate spec

OpenAPI for HTTP POST /<method_name>:

println!("{}", gen_openapi_yaml::<SomeAPI>());

OpenRPC for JsonRPC:

println!("{}", gen_openrpc_yaml::<SomeAPI>());

Generate TS types

println!("{}", gen_ts_api::<SomeAPI>());

Current implementation works by inlining everything, which is probably undesirable:

type Request<M> =
  M extends 'get_a' ? null :
  M extends 'post_a' ? boolean :
  void;

type Response<M> =
  M extends 'get_a' ? Result<boolean, number> :
  M extends 'post_a' ? Result<null, number> :
  void;

TS boilerplate would look something like this:

const callSomeApi<M> = async (req: Request<M>) => {
  const raw_response = await fetch(`http://example.com/{method}`, {
    method: 'POST',
    body: req,
    headers: { 'Content-Type': 'application/json' }
  });
  const json = await raw_response.json();
  json as Response<M>
}

And to unwrap rust's Result:

function unwrapResult<R, E>(a: Result<R, E>): R {
  if ('Ok' in a) {
    return a.Ok;
  } else if ('Err' in a) {
    throw  Error(JSON.stringify(a.Err))
  } else {
    throw Error('non api error')
  }
}

Things to implement/improve

  • Allow for non-inlined TS types generation
  • Debug ts feature
  • no-std feature