2 unstable releases
0.3.0 | Nov 17, 2023 |
---|---|
0.1.0 | Oct 9, 2023 |
#35 in #json-api
Used in cloudconvert
53KB
845 lines
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.
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 implementsJsonApiCall
).JsonApiCall
: a JSON api call, allowing for conversion of the request and response (automatically implementsApiCall
).ApiCall
: a generic API call (automatically implementsRawApiCall
).RawApiCall
: the lowest level trait for API calls, you probably want to implement one of the others.
Dependencies
~6–17MB
~248K SLoC