use super::*;
#[test]
fn test_extract_path_params() {
assert_eq!(
extract_path_params("/v1/profiles/{profile_id}"),
vec!["profile_id"]
);
assert_eq!(
extract_path_params("/v1/profiles/{profile_id}/devices/{device_id}"),
vec!["profile_id", "device_id"]
);
assert!(extract_path_params("/v1/auth/login").is_empty());
}
#[test]
fn path_param_with_a_field_template_names_the_field() {
assert_eq!(extract_path_params("/v1/{name=shelves/*}"), vec!["name"]);
assert_eq!(extract_path_params("/v1/files/{path=**}"), vec!["path"]);
}
#[test]
fn test_docs_html_contains_scalar() {
let html = docs_html("/openapi.json", "Test API");
assert!(html.contains("@scalar/api-reference"));
assert!(html.contains("/openapi.json"));
assert!(html.contains("Test API"));
}
#[test]
fn test_well_known_schemas() {
let pool = DescriptorPool::global();
if let Some(ts) = pool.get_message_by_name("google.protobuf.Timestamp") {
let schema = well_known_schema(&ts);
assert_eq!(schema["type"], "string");
assert_eq!(schema["format"], "date-time");
}
}
fn config() -> OpenApiConfig {
OpenApiConfig {
enabled: true,
path: "/openapi.json".into(),
docs_path: "/docs".into(),
title: Some("Test API".into()),
version: Some("0.1.0".into()),
}
}
#[test]
fn test_generate_empty_pool() {
let pool = DescriptorPool::new();
let spec = generate(&pool, &config(), &[], &RpcSelection::default());
assert_eq!(spec["openapi"], "3.0.3");
assert_eq!(spec["info"]["title"], "Test API");
assert_eq!(spec["info"]["version"], "0.1.0");
assert!(spec["paths"].as_object().unwrap().is_empty());
}
#[test]
fn test_field_to_schema_primitives() {
let schema = json!({ "type": "string" });
assert_eq!(schema["type"], "string");
let int_schema = json!({ "type": "integer", "format": "int32" });
assert_eq!(int_schema["format"], "int32");
let i64_schema = json!({ "type": "string", "format": "int64", "description": "64-bit integer (string-encoded)" });
assert_eq!(i64_schema["type"], "string");
assert_eq!(i64_schema["format"], "int64");
}
const HTTP_PROTO: &str = r#"
syntax = "proto3";
package google.api;
message HttpRule {
string selector = 1;
oneof pattern {
string get = 2;
string put = 3;
string post = 4;
string delete = 5;
string patch = 6;
CustomHttpPattern custom = 8;
}
string body = 7;
string response_body = 12;
repeated HttpRule additional_bindings = 11;
}
message CustomHttpPattern {
string kind = 1;
string path = 2;
}
"#;
const ANNOTATIONS_PROTO: &str = r#"
syntax = "proto3";
package google.api;
import "google/api/http.proto";
import "google/protobuf/descriptor.proto";
extend google.protobuf.MethodOptions {
HttpRule http = 72295728;
}
"#;
const HTTPBODY_PROTO: &str = r#"
syntax = "proto3";
package google.api;
import "google/protobuf/any.proto";
message HttpBody {
string content_type = 1;
bytes data = 2;
repeated google.protobuf.Any extensions = 3;
}
"#;
const API_PROTO: &str = r#"
syntax = "proto3";
package test.v1;
import "google/api/annotations.proto";
import "google/api/httpbody.proto";
message Item {
string name = 1;
int32 count = 2;
Note note = 3;
}
message Note {
string text = 1;
}
message Filter {
string q = 1;
}
message Search {
Filter filter = 1;
}
message Upload {
string name = 1;
google.api.HttpBody file = 2;
}
message Tree {
string name = 1;
Tree child = 2;
}
service Api {
rpc Head(Item) returns (Item) {
option (google.api.http) = { custom: { kind: "HEAD", path: "/v1/items/{name}" } };
}
rpc Verify(Item) returns (Item) {
option (google.api.http) = { custom: { kind: "*", path: "/v1/verify" } };
}
rpc Propfind(Item) returns (Item) {
option (google.api.http) = { custom: { kind: "PROPFIND", path: "/v1/dav" } };
}
rpc Create(Item) returns (Item) {
option (google.api.http) = {
post: "/v1/items"
body: "*"
additional_bindings { put: "/v1/items/{name}" body: "note" }
additional_bindings { post: "/v1/items:touch" }
additional_bindings { get: "/v1/items/{name}/note" response_body: "note" }
};
}
rpc Walk(Tree) returns (Tree) {
option (google.api.http) = { get: "/v1/tree" };
}
rpc Plant(Tree) returns (Tree) {
option (google.api.http) = { post: "/v1/tree" body: "*" };
}
rpc Put(Upload) returns (google.api.HttpBody) {
option (google.api.http) = { put: "/v1/uploads/{name}" body: "file" };
}
rpc Find(Search) returns (Item) {
option (google.api.http) = { get: "/v1/find" };
}
rpc Raw(google.api.HttpBody) returns (google.api.HttpBody) {
option (google.api.http) = { post: "/v1/raw" body: "*" };
}
rpc Archive(Item) returns (Item) {
option (google.api.http) = { post: "/v1/items/{name}:archive" };
}
rpc Broken(Item) returns (Item) {
option (google.api.http) = { get: "/v1/broken/a{name}b" };
}
rpc Shelved(Item) returns (Item) {
option (google.api.http) = { get: "/v1/shelf/{name=foo%3Abar/*}/books" };
}
rpc Shadowed(Item) returns (Item) {
option (google.api.http) = { get: "/v1/shelf/{name=**}" };
}
}
"#;
struct TestProtos;
impl protox::file::FileResolver for TestProtos {
fn open_file(&self, name: &str) -> Result<protox::file::File, protox::Error> {
let source = match name {
"google/api/http.proto" => HTTP_PROTO,
"google/api/annotations.proto" => ANNOTATIONS_PROTO,
"google/api/httpbody.proto" => HTTPBODY_PROTO,
"test/v1/api.proto" => API_PROTO,
_ => return protox::file::GoogleFileResolver::new().open_file(name),
};
protox::file::File::from_source(name, source)
}
}
fn spec(aliases: &[AliasConfig]) -> Value {
spec_of(aliases, &[])
}
fn spec_of(aliases: &[AliasConfig], only: &[&str]) -> Value {
let pool = protox::Compiler::with_file_resolver(TestProtos)
.open_file("test/v1/api.proto")
.unwrap()
.descriptor_pool();
let selection = RpcSelection::new(&pool, only).unwrap();
generate(&pool, &config(), aliases, &selection)
}
#[test]
fn custom_head_rule_is_a_head_operation() {
let spec = spec(&[]);
let item = &spec["paths"]["/v1/items/{name}"];
assert_eq!(item["head"]["operationId"], "Api.Head");
assert_eq!(item["head"]["parameters"][0]["in"], "path");
}
#[test]
fn custom_verb_after_a_variable_keeps_its_path_and_parameter() {
let spec = spec(&[]);
let archive = &spec["paths"]["/v1/items/{name}:archive"]["post"];
assert_eq!(archive["operationId"], "Api.Archive");
let params = archive["parameters"].as_array().unwrap();
let path_params: Vec<&Value> = params.iter().filter(|p| p["in"] == "path").collect();
assert_eq!(path_params.len(), 1);
assert_eq!(path_params[0]["name"], "name");
assert!(spec["paths"]["/v1/items/{name}"].get("post").is_none());
}
#[test]
fn a_route_the_router_refuses_beside_another_is_not_documented() {
let spec = spec(&[]);
let paths = spec["paths"].as_object().unwrap();
assert!(
paths.contains_key("/v1/shelf/{name=foo%3Abar/*}/books"),
"{paths:?}"
);
assert!(!paths.contains_key("/v1/shelf/{name=**}"), "{paths:?}");
}
#[test]
fn a_template_the_router_cannot_serve_is_not_documented() {
let spec = spec(&[]);
let paths = spec["paths"].as_object().unwrap();
assert!(!paths.contains_key("/v1/broken/a{name}b"), "{paths:?}");
assert!(!paths.values().any(|item| item
.as_object()
.unwrap()
.values()
.any(|op| op["operationId"] == "Api.Broken")));
}
#[test]
fn star_rule_is_listed_under_every_method_with_distinct_ids() {
let spec = spec(&[]);
let verify = spec["paths"]["/v1/verify"].as_object().unwrap();
let mut methods: Vec<&str> = verify.keys().map(String::as_str).collect();
methods.sort_unstable();
let mut expected = OPERATIONS.to_vec();
expected.sort_unstable();
assert_eq!(methods, expected);
let ids: HashSet<&str> = verify
.values()
.map(|op| op["operationId"].as_str().unwrap())
.collect();
assert_eq!(ids.len(), OPERATIONS.len());
assert!(ids.contains("Api.Verify_head"));
}
#[test]
fn extension_method_has_no_openapi_operation() {
assert!(spec(&[])["paths"].get("/v1/dav").is_none());
}
#[test]
fn additional_bindings_are_listed() {
let spec = spec(&[]);
assert_eq!(
spec["paths"]["/v1/items"]["post"]["operationId"],
"Api.Create"
);
assert_eq!(
spec["paths"]["/v1/items/{name}"]["put"]["operationId"],
"Api.Create_2"
);
assert_eq!(
spec["paths"]["/v1/items:touch"]["post"]["operationId"],
"Api.Create_3"
);
}
#[test]
fn body_rule_decides_body_and_query_fields() {
let spec = spec(&[]);
let create = &spec["paths"]["/v1/items"]["post"];
assert_eq!(
create["requestBody"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/Item"
);
assert!(create.get("parameters").is_none(), "{create}");
let put = &spec["paths"]["/v1/items/{name}"]["put"];
assert_eq!(
put["requestBody"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/Note"
);
let params: Vec<(&str, &str)> = put["parameters"]
.as_array()
.unwrap()
.iter()
.map(|p| (p["name"].as_str().unwrap(), p["in"].as_str().unwrap()))
.collect();
assert_eq!(params, [("name", "path"), ("count", "query")]);
let touch = &spec["paths"]["/v1/items:touch"]["post"];
assert!(touch.get("requestBody").is_none(), "{touch}");
assert_eq!(touch["parameters"].as_array().unwrap().len(), 3);
}
#[test]
fn message_typed_query_parameter_schema_is_registered() {
let spec = spec(&[]);
let param = &spec["paths"]["/v1/find"]["get"]["parameters"][0];
assert_eq!(param["in"], "query");
assert_eq!(param["schema"]["$ref"], "#/components/schemas/Filter");
assert!(
spec["components"]["schemas"].get("Filter").is_some(),
"{}",
spec["components"]["schemas"]
);
}
#[test]
fn self_referencing_message_does_not_recurse_forever() {
let spec = spec(&[]);
let tree = &spec["components"]["schemas"]["Tree"];
assert_eq!(
tree["properties"]["child"]["$ref"],
"#/components/schemas/Tree"
);
assert_eq!(
spec["paths"]["/v1/tree"]["get"]["parameters"][1]["schema"]["$ref"],
"#/components/schemas/Tree"
);
assert!(spec["paths"]["/v1/tree"]["post"]["requestBody"].is_object());
}
#[test]
fn head_operations_describe_no_response_content() {
let spec = spec(&[]);
for path in ["/v1/items/{name}", "/v1/verify"] {
let head = &spec["paths"][path]["head"]["responses"]["200"];
assert!(head.is_object(), "{path}");
assert!(head.get("content").is_none(), "{path}: {head}");
}
assert!(spec["paths"]["/v1/verify"]["get"]["responses"]["200"]["content"].is_object());
}
#[test]
fn json_response_body_is_described_by_the_selected_field() {
let spec = spec(&[]);
let content = &spec["paths"]["/v1/items/{name}/note"]["get"]["responses"]["200"]["content"];
assert_eq!(
content["application/json"]["schema"]["$ref"],
"#/components/schemas/Note"
);
}
#[test]
fn http_body_request_and_response_are_raw_content() {
let spec = spec(&[]);
let raw = json!({ "*/*": { "schema": { "type": "string", "format": "binary" } } });
let put = &spec["paths"]["/v1/uploads/{name}"]["put"];
assert_eq!(put["requestBody"]["content"], raw);
assert_eq!(put["responses"]["200"]["content"], raw);
let root = &spec["paths"]["/v1/raw"]["post"];
assert_eq!(root["requestBody"]["content"], raw);
assert_eq!(root["responses"]["200"]["content"], raw);
}
#[test]
fn aliases_get_their_own_operation_ids() {
let alias: AliasConfig = serde_yaml::from_str("from: /api/{path}\nto: /v1").unwrap();
let spec = spec(&[alias]);
assert_eq!(
spec["paths"]["/v1/items"]["post"]["operationId"],
"Api.Create"
);
let aliased = &spec["paths"]["/api/items"]["post"]["operationId"];
assert!(aliased.is_string());
assert_ne!(aliased, "Api.Create");
}
#[test]
fn a_selection_documents_only_the_rpcs_it_transcodes() {
let spec = spec_of(&[], &["test.v1.Api/Find"]);
let paths = spec["paths"].as_object().unwrap();
assert_eq!(paths.keys().collect::<Vec<_>>(), ["/v1/find"]);
assert_eq!(spec["tags"][0]["name"], "Api");
let spec = spec_of(&[], &["test.v1.Api"]);
assert!(spec["paths"]["/v1/items"]["post"].is_object());
}
#[test]
fn a_selection_naming_nothing_in_the_descriptors_is_an_error() {
let pool = protox::Compiler::with_file_resolver(TestProtos)
.open_file("test/v1/api.proto")
.unwrap()
.descriptor_pool();
for name in ["test.v1.Nope", "test.v1.Api/Nope", "Api"] {
let err = RpcSelection::new(&pool, [name]).unwrap_err();
assert!(err.contains(name), "{err}");
}
}