use alux_ext::ext;
use alux_http::{
EmptyOutAlg, HttpApiAlg, HttpProgramExt, JsonOutAlg, NamedValuesAlg, ResultOutAlg, StatusOutAlg, http,
};
use alux_http_openapi::OpenApiHandlerImpl;
use alux_http_text::TextHandlerImpl;
use alux_shape::Shape;
use core::future::Future;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::io::{Error as IoError, ErrorKind};
#[derive(Debug, Serialize, Deserialize, Shape)]
pub struct Reading {
pub id: u64,
pub value: u32,
pub note: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Shape)]
pub struct Term {
pub term: String,
}
impl NamedValuesAlg for Term {}
trait ReadingsAlg {
fn reading(&self, id: u64) -> impl Future<Output = Result<Reading, IoError>> + Send;
fn record(&self, reading: Reading) -> impl Future<Output = Reading> + Send;
fn search(&self, term: String) -> impl Future<Output = Vec<Reading>> + Send;
fn forget(&self) -> impl Future<Output = ()> + Send;
}
#[ext(name = ReadingsOperationExt, defunc)]
impl<This> This
where
This: ReadingsAlg,
{
async fn reading_at(&self, id: u64) -> Result<Reading, IoError> {
self.reading(id).await
}
async fn reading_record(&self, reading: Reading) -> Reading {
self.record(reading).await
}
async fn reading_search(&self, term: Term) -> Vec<Reading> {
self.search(term.term).await
}
async fn reading_forget(&self) {
self.forget().await;
}
}
#[ext(name = ReadingsApiExt, defunc(via = http))]
impl<This> This
where
This: HttpApiAlg + JsonOutAlg + EmptyOutAlg + StatusOutAlg + ResultOutAlg,
{
fn readings_api<Alg>(&self)
where
Alg: ReadingsAlg,
{
self.routes()
.get("/readings/:id", self.op(Alg::reading_at).path::<u64>().json().result())
.get("/readings", self.op(Alg::reading_search).query::<Term>().json())
.post("/readings", self.op(Alg::reading_record).body::<Reading>().json().status::<201>())
.delete("/readings", self.op(Alg::reading_forget).empty())
}
}
struct Readings;
impl ReadingsAlg for Readings {
async fn reading(&self, id: u64) -> Result<Reading, IoError> {
match id {
1 => Ok(Reading { id, value: 7, note: None }),
_ => Err(IoError::new(ErrorKind::NotFound, "no such reading")),
}
}
async fn record(&self, reading: Reading) -> Reading {
reading
}
async fn search(&self, _term: String) -> Vec<Reading> {
Vec::new()
}
async fn forget(&self) {}
}
fn document() -> Value {
let api = OpenApiHandlerImpl::<Readings>::new();
let route = api.compile_http(api.readings_api::<Readings>());
api.document("readings", "1.0", &route)
}
fn operation(document: &Value, path: &str, method: &str) -> Value {
document["paths"][path][method].clone()
}
#[test]
fn keys_an_operation_by_the_path_and_method_it_answers_on() {
let document = document();
assert_eq!(document["openapi"], "3.1.0");
assert_eq!(document["info"]["title"], "readings");
let mut paths = document["paths"].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
paths.sort();
assert_eq!(paths, ["/readings", "/readings/{id}"]);
let methods = document["paths"]["/readings"].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
assert_eq!(methods, ["delete", "get", "post"]);
}
#[test]
fn names_each_operation_as_it_was_declared() {
let document = document();
assert_eq!(operation(&document, "/readings/{id}", "get")["operationId"], "reading_at");
assert_eq!(operation(&document, "/readings", "post")["operationId"], "reading_record");
assert_eq!(operation(&document, "/readings", "delete")["operationId"], "reading_forget");
}
#[test]
fn describes_each_argument_where_the_declaration_reads_it_from() {
let document = document();
assert_eq!(
operation(&document, "/readings/{id}", "get")["parameters"],
json!([{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
}])
);
assert_eq!(
operation(&document, "/readings", "get")["parameters"],
json!([{
"name": "term",
"in": "query",
"required": true,
"schema": { "type": "string" },
}])
);
let posted = operation(&document, "/readings", "post");
assert_eq!(posted["requestBody"]["required"], true);
assert_eq!(
posted["requestBody"]["content"]["application/json"]["schema"],
json!({ "$ref": "#/components/schemas/Reading" })
);
assert!(posted["parameters"].is_null());
}
#[test]
fn states_the_shape_of_everything_that_crosses_the_wire() {
let document = document();
assert_eq!(
document["components"]["schemas"]["Reading"],
json!({
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64", "minimum": 0 },
"value": { "type": "integer", "format": "int32", "minimum": 0 },
"note": { "anyOf": [{ "type": "string" }, { "type": "null" }] },
},
"required": ["id", "value"],
})
);
let searched = operation(&document, "/readings", "get");
assert_eq!(
searched["responses"]["200"]["content"]["application/json"]["schema"],
json!({
"type": "array",
"items": { "$ref": "#/components/schemas/Reading" },
})
);
}
#[test]
fn states_every_status_an_endpoint_can_answer_with() {
let document = document();
let created = operation(&document, "/readings", "post");
assert_eq!(created["responses"].as_object().unwrap().keys().collect::<Vec<_>>(), ["201"]);
let forgotten = operation(&document, "/readings", "delete");
assert_eq!(forgotten["responses"], json!({ "204": { "description": "Forgets every reading." } }));
let read = operation(&document, "/readings/{id}", "get");
let statuses = read["responses"].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
assert_eq!(statuses, ["200", "403", "404", "500"]);
assert_eq!(read["responses"]["404"]["content"]["text/plain"]["schema"], json!({ "type": "string" }));
}
#[test]
fn describes_the_surface_the_other_interpretations_compile() {
let api = OpenApiHandlerImpl::<Readings>::new();
let text = TextHandlerImpl;
let described = text.compile_http(text.readings_api::<Readings>());
let documented = api.compile_http(api.readings_api::<Readings>());
assert_eq!(documented.labels(), described.labels());
assert_eq!(documented.labels(), ["GET /readings/{id}", "GET /readings", "POST /readings", "DELETE /readings",]);
assert_eq!(documented.operations(), ["reading_at", "reading_search", "reading_record", "reading_forget"]);
}
#[test]
fn says_what_each_operation_is_for() {
let document = document();
let read = operation(&document, "/readings/{id}", "get");
assert_eq!(read["summary"], "Returns one identified reading, or why it could not be read.");
assert!(read["description"].is_null());
assert_eq!(read["responses"]["200"]["description"], "Returns one identified reading, or why it could not be read.");
assert_eq!(read["responses"]["404"]["description"], "");
}
#[test]
fn keys_a_collection_of_names_and_values_by_each_name_it_carries() {
use alux_http_conformance::{Shop, ShopApiExt};
let api = OpenApiHandlerImpl::<Shop>::new();
let route = api.compile_http(api.shop_api::<Shop>());
let document = api.document("shop", "1.0", &route);
assert_eq!(
document["paths"]["/session"]["get"]["parameters"],
json!([{
"name": "session",
"in": "cookie",
"required": true,
"schema": { "type": "string" },
}])
);
assert_eq!(
document["paths"]["/agent"]["get"]["parameters"],
json!([{
"name": "user-agent",
"in": "header",
"required": true,
"schema": { "type": "string" },
}])
);
}
#[derive(Debug, Serialize, Deserialize, Shape)]
pub struct Filters {
pub since: u64,
pub limit: Option<u32>,
}
impl NamedValuesAlg for Filters {}
trait FilteredAlg {
fn filtered(&self, filters: Filters) -> impl Future<Output = Vec<Reading>> + Send;
}
#[ext(name = FilteredOperationExt, defunc)]
impl<This> This
where
This: FilteredAlg,
{
async fn reading_filtered(&self, filters: Filters) -> Vec<Reading> {
self.filtered(filters).await
}
}
#[ext(name = FilteredApiExt, defunc(via = http))]
impl<This> This
where
This: HttpApiAlg + JsonOutAlg,
{
fn filtered_api<Alg>(&self)
where
Alg: FilteredAlg,
{
self.routes().get("/filtered", self.op(Alg::reading_filtered).query::<Filters>().json())
}
}
impl FilteredAlg for Readings {
async fn filtered(&self, _filters: Filters) -> Vec<Reading> {
Vec::new()
}
}
#[test]
fn keys_a_query_read_into_a_product_by_each_member_it_carries() {
let api = OpenApiHandlerImpl::<Readings>::new();
let route = api.compile_http(api.filtered_api::<Readings>());
let document = api.document("readings", "1.0", &route);
assert_eq!(
operation(&document, "/filtered", "get")["parameters"],
json!([
{ "name": "limit", "in": "query", "required": false, "schema": { "anyOf": [{ "type": "integer", "format": "int32", "minimum": 0 }, { "type": "null" }] } },
{ "name": "since", "in": "query", "required": true, "schema": { "type": "integer", "format": "int64", "minimum": 0 } },
])
);
}
#[test]
fn states_every_header_an_answer_carries() {
use alux_http_conformance::{Shop, ShopApiExt};
let api = OpenApiHandlerImpl::<Shop>::new();
let route = api.compile_http(api.shop_api::<Shop>());
let document = api.document("shop", "1.0", &route);
let cached = &document["paths"]["/cached"]["get"]["responses"]["200"];
assert_eq!(cached["headers"], json!({ "cache-control": { "schema": { "type": "string" } } }));
assert!(cached["content"]["application/json"]["schema"].is_object());
}