#![allow(clippy::result_large_err)]
mod common;
use common::{Served, at, items, member};
use lino_objects_codec::LinoValue;
use lino_rest_api::app::{LinoApp, create_lino_app};
use lino_rest_api::client::{
DEFAULT_ACCEPT, LinoClient, LinoClientError, RequestOptions, create_lino_client,
};
use lino_rest_api::description::ServiceInfo;
use lino_rest_api::media_type::{JSON_CONTENT_TYPE, LINO_CONTENT_TYPE};
use lino_rest_api::problem::LinoHttpError;
use lino_rest_api::resource::ResourceOptions;
use lino_rest_api::response::{no_content, raw_response};
use lino_rest_api::store::MemoryStore;
use lino_rest_api::value::{array, as_array, boolean, int, object, string};
fn build_app() -> LinoApp {
let mut app = create_lino_app().with_info(ServiceInfo::new("Items API", "1.0.0"));
let store = MemoryStore::seeded([object([("name", string("first"))])]);
app.resource(
"/items",
common::shared(store),
ResourceOptions::new().with_name("item"),
);
app.get_fn("/health", "Health check", |_request| {
Ok(object([("status", string("ok"))]))
});
app.get_fn("/empty", "Nothing at all", |_request| Ok(no_content()));
app.get_fn("/boom", "Always conflicts", |_request| {
Err::<LinoValue, _>(LinoHttpError::new(409, Some("Already exists")))
});
app
}
fn plain() -> RequestOptions {
RequestOptions::new()
}
#[test]
fn the_client_asks_for_links_notation_first() {
assert_eq!(DEFAULT_ACCEPT, "text/lino, application/json;q=0.5");
assert!(DEFAULT_ACCEPT.starts_with(LINO_CONTENT_TYPE));
assert!(DEFAULT_ACCEPT.contains(JSON_CONTENT_TYPE));
}
#[test]
fn a_trailing_slash_on_the_base_url_is_not_repeated_in_every_path() {
let client = create_lino_client("http://example.com/");
assert_eq!(client.base_url(), "http://example.com");
assert_eq!(
LinoClient::new("http://example.com").base_url(),
"http://example.com"
);
}
#[test]
fn query_strings_expand_repeated_values() {
assert_eq!(plain().query.to_query_string(), "");
let options = plain()
.with_query("a", &int(1))
.with_query("b", &int(2))
.with_query("b", &int(3));
assert_eq!(options.query.to_query_string(), "a=1&b=2&b=3");
}
#[test]
fn query_strings_spell_booleans_the_way_the_filters_read_them() {
let options = plain()
.with_query("done", &boolean(false))
.with_query("ok", &boolean(true));
assert_eq!(options.query.to_query_string(), "done=false&ok=true");
}
#[tokio::test]
async fn the_client_encodes_requests_and_decodes_responses() {
let api = Served::start(build_app()).await;
let health = api
.client
.get("/health", &plain())
.await
.expect("a health check");
assert_eq!(health.status, 200);
assert_eq!(health.value(), object([("status", string("ok"))]));
let created = api
.client
.post("/items", &object([("name", string("second"))]), &plain())
.await
.expect("an item is created");
assert_eq!(created.status, 201);
assert_eq!(created.location.as_deref(), Some("/items/2"));
assert_eq!(member(&created.value(), "name"), &string("second"));
}
#[tokio::test]
async fn a_4xx_raises_a_typed_error_carrying_the_problem_details() {
let api = Served::start(build_app()).await;
let error = api
.client
.get("/boom", &plain())
.await
.expect_err("the route always conflicts");
assert_eq!(error.status(), Some(409));
let problem = error.problem().expect("the body carries problem details");
assert_eq!(member(problem, "title"), &string("Conflict"));
assert_eq!(error.to_string(), "Already exists");
}
#[tokio::test]
async fn a_204_is_the_absence_of_a_representation_not_a_null_one() {
let api = Served::start(build_app()).await;
let response = api.client.get("/empty", &plain()).await.expect("an answer");
assert_eq!(response.status, 204);
assert_eq!(response.data, None);
assert_eq!(response.value(), LinoValue::Null);
}
#[tokio::test]
async fn the_client_honours_conditional_requests() {
let api = Served::start(build_app()).await;
let first = api
.client
.get("/items/1", &plain())
.await
.expect("the item");
let tag = first.etag.clone().expect("a strong tag");
let cached = api
.client
.get("/items/1", &plain().if_none_match(tag.clone()))
.await
.expect("a conditional read");
assert_eq!(cached.status, 304);
assert_eq!(cached.data, None);
let updated = api
.client
.patch(
"/items/1",
&object([("done", boolean(true))]),
&plain().if_match(tag.clone()),
)
.await
.expect("the precondition holds");
assert_eq!(member(&updated.value(), "done"), &boolean(true));
let stale = api
.client
.patch(
"/items/1",
&object([("done", boolean(false))]),
&plain().if_match(tag),
)
.await
.expect_err("the representation has changed");
assert_eq!(stale.status(), Some(412));
}
#[tokio::test]
async fn the_client_can_list_a_collection() {
let api = Served::start(build_app()).await;
let envelope = api
.client
.list("/items", &plain().with_query("limit", &int(1)))
.await
.expect("a collection");
assert_eq!(items(&envelope).len(), 1);
assert_eq!(at(&envelope, &["page", "limit"]), &int(1));
}
#[tokio::test]
async fn the_client_reads_the_advertised_methods() {
let api = Served::start(build_app()).await;
assert_eq!(
api.client
.options("/health", &plain())
.await
.expect("an answer"),
vec!["GET", "HEAD", "OPTIONS"]
);
}
#[tokio::test]
async fn head_yields_headers_without_a_body() {
let api = Served::start(build_app()).await;
let response = api
.client
.head("/health", &plain())
.await
.expect("an answer");
assert_eq!(response.status, 200);
assert_eq!(response.data, None);
assert!(response.etag.is_some());
}
#[tokio::test]
async fn the_client_can_read_the_service_description() {
let api = Served::start(build_app()).await;
let description = api.client.describe().await.expect("a description");
assert_eq!(at(&description, &["info", "title"]), &string("Items API"));
let routes = as_array(member(&description, "routes")).expect("routes is a list");
assert!(
routes
.iter()
.any(|route| member(route, "path") == &string("/items"))
);
}
#[tokio::test]
async fn the_request_representation_can_be_switched_to_json() {
let api = Served::start(build_app()).await;
let client = LinoClient::new(api.base.clone())
.with_accept(JSON_CONTENT_TYPE)
.with_content_type(JSON_CONTENT_TYPE);
let created = client
.post("/items", &object([("name", string("json"))]), &plain())
.await
.expect("an item is created");
assert_eq!(created.status, 201);
assert_eq!(
created.headers.get("content-type"),
Some("application/json; charset=utf-8")
);
assert_eq!(member(&created.value(), "name"), &string("json"));
}
#[tokio::test]
async fn the_client_deletes_a_resource() {
let api = Served::start(build_app()).await;
assert_eq!(
api.client
.delete("/items/1", &plain())
.await
.expect("a deletion")
.status,
204
);
let missing = api
.client
.get("/items/1", &plain())
.await
.expect_err("the item is gone");
assert_eq!(missing.status(), Some(404));
}
#[tokio::test]
async fn a_boolean_filter_is_sent_in_the_spelling_the_server_parses() {
let mut app = create_lino_app();
let store = MemoryStore::seeded([
object([("name", string("open")), ("done", boolean(false))]),
object([("name", string("shut")), ("done", boolean(true))]),
]);
app.resource("/items", common::shared(store), ResourceOptions::new());
let api = Served::start(app).await;
let envelope = api
.client
.list("/items", &plain().with_query("done", &boolean(false)))
.await
.expect("a collection");
let names: Vec<&LinoValue> = items(&envelope)
.iter()
.map(|item| member(item, "name"))
.collect();
assert_eq!(names, vec![&string("open")]);
}
#[tokio::test]
async fn a_client_header_is_sent_with_every_request() {
let mut app = LinoApp::new();
app.get_fn("/echo-header", "Echo a request header", |request| {
Ok(object([(
"seen",
request
.headers
.get("X-Trace")
.map(string)
.unwrap_or(LinoValue::Null),
)]))
});
let api = Served::start(app).await;
let client = LinoClient::new(api.base.clone()).with_header("X-Trace", "abc");
let response = client
.get("/echo-header", &plain())
.await
.expect("an answer");
assert_eq!(member(&response.value(), "seen"), &string("abc"));
let overridden = client
.get("/echo-header", &plain().with_header("X-Trace", "xyz"))
.await
.expect("an answer");
assert_eq!(member(&overridden.value(), "seen"), &string("xyz"));
}
#[tokio::test]
async fn a_representation_the_client_cannot_decode_is_handed_over_as_text() {
let mut app = LinoApp::new();
app.get_fn("/report", "A report", |_request| {
Ok(raw_response("name,id\nfirst,1\n", "text/csv", 200))
});
let api = Served::start(app).await;
let response = api
.client
.get("/report", &plain())
.await
.expect("an answer");
assert_eq!(response.status, 200);
assert_eq!(response.data, None);
assert_eq!(response.text, "name,id\nfirst,1\n");
}
#[tokio::test]
async fn a_transport_failure_is_reported_as_such() {
let client = LinoClient::new("http://127.0.0.1:1");
let error = client
.get("/health", &plain())
.await
.expect_err("nothing is listening");
assert!(matches!(error, LinoClientError::Transport(_)));
assert_eq!(error.status(), None);
assert_eq!(error.problem(), None);
}
#[tokio::test]
async fn query_parameters_survive_the_round_trip_to_the_server() {
let mut app = LinoApp::new();
app.get_fn("/echo-query", "Echo the query", |request| {
Ok(object([(
"tags",
array(
request
.query()
.get_all("tag")
.into_iter()
.map(string)
.collect::<Vec<_>>(),
),
)]))
});
let api = Served::start(app).await;
let response = api
.client
.get(
"/echo-query",
&plain()
.with_query_text("tag", "a b")
.with_query_text("tag", "é"),
)
.await
.expect("an answer");
assert_eq!(
member(&response.value(), "tags"),
&array([string("a b"), string("é")])
);
}