#json-api #http-client #http-api #json #http #api

hapic

HTTP API Client (hapic): A Rust crate for quickly creating nice-to-use client libraries for HTTP APIs, in particular, there's lots of tooling around HTTP JSON APIs

2 unstable releases

0.3.0 Nov 17, 2023
0.1.0 Oct 9, 2023

#35 in #json-api


Used in cloudconvert

MIT license

53KB
845 lines

HTTP API Client (hapic)

Crates.io docs.rs MIT License

A Rust crate for quickly creating nice-to-use client libraries for HTTP APIs, in particular, there's lots of tooling around HTTP JSON APIs.

This is still a work in progress.

Examples

For examples with an explanation, see the crate documentation.

For a complete client, see the cloudconvert crate.

Tests

For the tests, you need to be running ./test-api-server (just use cargo run).

Then, just use cargo test within ./hapic.


lib.rs:

A Rust crate for quickly creating nice-to-use client libraries for HTTP APIs, in particular, there's lots of tooling around HTTP JSON APIs.

This is still a work in progress.

Example: defining a JSON API client

Super Simple

We'll start with a simple dummy API, it has the following endpoints:

>> POST /add
>> { "a": 2, "b": 3 }
<< { "c": 5 }

>> POST /sub
>> { "a": 6, "b": 3 }
<< { "c": 3 }

>> POST /factorial
>> { "a": 4 }
<< { "c": 24 }

We can define a client for this API as such:

use hapic::json_api;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Add {
    a: u32,
    b: u32,
}

#[derive(Serialize)]
struct Sub {
    a: u32,
    b: u32,
}

#[derive(Serialize)]
struct Factorial {
    a: u8,
}

#[derive(Deserialize, PartialEq, Eq, Debug)]
struct Output {
    c: u64,
}

json_api!(
    struct TestClient<B, T: Transport<B>>;
    trait TestApiCall;

    simple {
        "/add": Add => Output;
        "/sub": Sub => Output;
        "/factorial": Factorial => Output;
    }
);

Now, the call types (Add, Sub and Factorial) all implement TestApiCall.

We can make an API call (to http://localhost:8080) using:

#
#
#
#
#
#
let client = TestClient::new("http://localhost:8000".into());
let output = client.call(Add { a: 1, b: 2 }).await.unwrap();
assert_eq!(output, Output { c: 3 });

With Conversions

Now suppose we have a single endpoint for all these operations:

>> POST /op
>> { "a": 2, "b": 3, "operation": "add" }
<< { "c": 5 }

>> POST /op
>> { "a": 6, "b": 3, "operation": "sub" }
<< { "c": 3 }

>> POST /op
>> { "a": 4, "operation": "factorial" }
<< { "c": 24 }

We could define the type:

#[derive(serde::Serialize)]
struct Operation {
    operation: &'static str,
    a: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    b: Option<u32>,
}

This this isn't very idiomatic! Firstly, the user can put any string as the operation, but we know our API server only accepts add, sub or factorial. In addition, if b is Some in a factorial operation, or is None in add or sub, we'll have an invalid call.

We want to make this safe, so we can define two types, one for the API call, and another which will be sent as the JSON body.

#
enum Operation {
    Add((u32, u32)),
    Sub((u32, u32)),
    Factorial(u8),
}

#[derive(serde::Serialize)]
struct JsonOperation {
    operation: &'static str,
    a: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    b: Option<u32>,
}

impl From<Operation> for JsonOperation {
    fn from(op: Operation) -> JsonOperation {
        match op {
            Operation::Add((a, b)) => JsonOperation {
                operation: "add",
                a,
                b: Some(b),
            },
            Operation::Sub((a, b)) => JsonOperation {
                operation: "sub",
                a,
                b: Some(b),
            },
            Operation::Factorial(x) => JsonOperation {
                operation: "factorial",
                a: x as u32,
                b: None,
            },
        }
    }
}

json_api!(
    struct TestClient<B, T: Transport<B>>;
    trait TestApiCall;

    json {
        "/op": Operation as JsonOperation => Output as Output;
    }
);

Of course, we could have achieved the same thing using a custom serialization implementation, but in many cases (especially for deserializing the output) it's significantly faster to define two types and implement TryInto or Into between them.

Finally, we make another tweak. Out Output type is just a single number, so why not return a number as the result.

#
#
#
impl From<Output> for u64 {
    fn from(output: Output) -> u64 {
        output.c
    }
}

json_api!(
    struct TestClient<B, T: Transport<B>>;
    trait TestApiCall;

    json {
        "/op": Operation as JsonOperation => Output as u64;
    }
);

Now we can make API calls really neatly:

#
#
#
#
#
#
#
let client = TestClient::new(std::borrow::Cow::Borrowed("http://localhost:8000"));
let output = client.call(Operation::Add((1, 2))).await.unwrap();
assert_eq!(output, 3);

Real-World Example

For a real world example, see cloudconvert-rs.

Using the macros

In the simple examples above, we made use of the json_api! macro to generate the client and API calls.

The json_api! macro is just a shorthand way of using the client! macro, then the json_api_call! macro, both of which have detailed documentation.

Implementing Traits Directly

You can also (and in some cases will want to) implement the traits directly. Here's a summary, from most abstract to least abstract:

  • SimpleApiCall: a JSON API call where the input and output types undergo no conversion (automatically implements JsonApiCall).
  • JsonApiCall: a JSON api call, allowing for conversion of the request and response (automatically implements ApiCall).
  • ApiCall: a generic API call (automatically implements RawApiCall).
  • RawApiCall: the lowest level trait for API calls, you probably want to implement one of the others.

Dependencies

~6–17MB
~248K SLoC