Skip to content

Commit f3f7365

Browse files
committed
api: new endpoint that dynamically generate openapi from varlink IDL
This commit adds an endpoint for that generates openapi descriptions from the varlink IDL for a given interface. It is located under /openapi/{socket}/{interface} and is created dynamically. This is useful to bridge the gap between varlink and the wide openapi ecosytem. This allows to e.g. create fully typed interfaces for tyescript/react/go etc. This is using openapi 3.1 because it seems to be the most widely used one currently. Note that this seems to work well for most of the IDL and it maps nicely. The thing that does not map well is errors. The varlink IDL does only specify errors per interface but not per method. So currently errors are added as `components` but not part of the potential methods returns because we would have to add all errors as potential returns to all methods which feels a bit too much. Nullable is also added a bit simplistic. The other gap is "more" support, currently this is not discoverable from the IDL so we cannot make it part of the openapi output. We may need to have a convention in the method description or something, but that can be done in a followup. Only a small subset of calls uses "more" currently. E.g. ```json $ curl -s http://localhost:1031/openapi/io.systemd.MuteConsole/io.systemd.MuteConsole|jq { "info": { "description": "API for temporarily muting noisy output to the main kernel console", "title": "io.systemd.MuteConsole", "version": "0.0.0" }, "openapi": "3.1.0", "paths": { "/call/io.systemd.MuteConsole/io.systemd.MuteConsole.Mute": { "post": { "description": "Mute kernel and PID 1 output to the main kernel console\n[Requires 'more' flag]", "operationId": "Mute", "requestBody": { "content": { "application/json": { "schema": { "properties": { "kernel": { "description": "Whether to mute the kernel's output to the console (defaults to true).", "type": "boolean" }, "pid1": { "description": "Whether to mute PID1's output to the console (defaults to true).", "type": "boolean" } }, "type": "object" } } }, "required": true }, "responses": { "200": { "content": { "application/json": { "schema": { "properties": {}, "type": "object" } } }, "description": "Successful response" } } } } } } ```
1 parent 41ee789 commit f3f7365

4 files changed

Lines changed: 350 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ POST /call/{method} → invoke method (c.f. varlink call, sup
1616
GET /sockets → list available sockets (c.f. valinkctl list-registry)
1717
GET /sockets/{socket} → socket info (c.f. varlinkctl info)
1818
GET /sockets/{socket}/{interface} → interface details, including method names (c.f. varlinkctl list-methods)
19+
GET /openapi/{socket}/{interface} → OpenAPI 3.1 description generated from varlink IDL
1920
2021
GET /health → health check
2122
```

src/bin/varlink-httpd/main.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use zlink::varlink_service::Proxy;
3333
mod auth_ssh;
3434
#[cfg(feature = "sshauth")]
3535
mod import_ssh;
36+
mod openapi;
3637

3738
#[cfg(feature = "sshauth")]
3839
use auth_ssh::{extract_nonce, maybe_create_ssh_authenticator};
@@ -540,6 +541,27 @@ struct AppState {
540541
authenticators: Arc<Vec<Box<dyn Authenticator>>>,
541542
}
542543

544+
async fn route_openapi_get(
545+
ConnectInfo(conn_cache): ConnectInfo<VarlinkConnCache>,
546+
Path((socket, interface)): Path<(String, String)>,
547+
State(state): State<AppState>,
548+
) -> Result<axum::Json<Value>, AppError> {
549+
debug!("GET openapi for socket: {socket}, interface: {interface}");
550+
let conn_arc = get_varlink_connection(&socket, &state, &conn_cache).await?;
551+
let mut connection = conn_arc.lock().await;
552+
553+
let description = connection
554+
.get_interface_description(&interface)
555+
.await?
556+
.map_err(|e| AppError::bad_gateway(format!("service error: {e}")))?;
557+
558+
let iface: zlink::idl::Interface = description
559+
.parse()
560+
.map_err(|e| AppError::bad_gateway(format!("upstream IDL parse error: {e}")))?;
561+
562+
Ok(axum::Json(openapi::idl_to_openapi(&socket, &iface)))
563+
}
564+
543565
async fn route_sockets_get(State(state): State<AppState>) -> Result<axum::Json<Value>, AppError> {
544566
debug!("GET sockets");
545567
let all_sockets = state.varlink_sockets.list_sockets().await?;
@@ -830,6 +852,7 @@ fn create_router(
830852
"/sockets/{socket}/{interface}",
831853
get(route_socket_interface_get),
832854
)
855+
.route("/openapi/{socket}/{interface}", get(route_openapi_get))
833856
.route("/call/{method}", post(route_call_post))
834857
.route("/ws/sockets/{socket}", get(route_ws))
835858
.layer(axum::middleware::from_fn_with_state(

src/bin/varlink-httpd/openapi.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// SPDX-License-Identifier: LGPL-2.1-or-later
2+
3+
use serde_json::{Value, json};
4+
use zlink::idl::{CustomType, Field, Interface, Type};
5+
6+
fn type_to_schema(ty: &Type) -> Value {
7+
match ty {
8+
Type::Bool => json!({"type": "boolean"}),
9+
Type::Int => json!({"type": "integer", "format": "int64"}),
10+
Type::Float => json!({"type": "number"}),
11+
Type::String => json!({"type": "string"}),
12+
Type::ForeignObject | Type::Any => json!({"type": "object"}),
13+
Type::Custom(name) => {
14+
json!({"$ref": format!("#/components/schemas/{name}")})
15+
}
16+
Type::Optional(inner) => type_to_schema(inner.inner()),
17+
Type::Array(inner) => {
18+
json!({"type": "array", "items": type_to_schema(inner.inner())})
19+
}
20+
Type::Map(inner) => {
21+
json!({"type": "object", "additionalProperties": type_to_schema(inner.inner())})
22+
}
23+
Type::Object(fields) => fields_to_schema(fields.iter()),
24+
Type::Enum(variants) => {
25+
let names: Vec<&str> = variants.iter().map(|v| v.name()).collect();
26+
json!({"type": "string", "enum": names})
27+
}
28+
}
29+
}
30+
31+
fn fields_to_schema<'a>(fields: impl Iterator<Item = &'a Field<'a>>) -> Value {
32+
let mut properties = serde_json::Map::new();
33+
let mut required = Vec::new();
34+
35+
for field in fields {
36+
let mut schema = type_to_schema(field.ty());
37+
if let Some(desc) = comments_to_string(field.comments()) {
38+
if let Value::Object(ref mut map) = schema {
39+
map.insert("description".to_string(), json!(desc));
40+
}
41+
}
42+
properties.insert(field.name().to_string(), schema);
43+
if !matches!(field.ty(), Type::Optional(_)) {
44+
required.push(json!(field.name()));
45+
}
46+
}
47+
48+
let mut schema = serde_json::Map::new();
49+
schema.insert("type".to_string(), json!("object"));
50+
schema.insert("properties".to_string(), Value::Object(properties));
51+
if !required.is_empty() {
52+
schema.insert("required".to_string(), Value::Array(required));
53+
}
54+
Value::Object(schema)
55+
}
56+
57+
fn comments_to_string<'a>(
58+
comments: impl Iterator<Item = &'a zlink::idl::Comment<'a>>,
59+
) -> Option<String> {
60+
let parts: Vec<&str> = comments.map(|c| c.content()).collect();
61+
(!parts.is_empty()).then(|| parts.join("\n"))
62+
}
63+
64+
pub fn idl_to_openapi(address: &str, iface: &Interface) -> Value {
65+
let mut paths = serde_json::Map::new();
66+
67+
for method in iface.methods() {
68+
let full_method = format!("{}.{}", iface.name(), method.name());
69+
let path = format!("/call/{address}/{full_method}");
70+
71+
let mut operation = serde_json::Map::new();
72+
operation.insert("operationId".to_string(), json!(method.name()));
73+
if let Some(desc) = comments_to_string(method.comments()) {
74+
operation.insert("description".to_string(), json!(desc));
75+
}
76+
operation.insert(
77+
"requestBody".to_string(),
78+
json!({
79+
"required": true,
80+
"content": {
81+
"application/json": {
82+
"schema": fields_to_schema(method.inputs())
83+
}
84+
}
85+
}),
86+
);
87+
let output_schema = fields_to_schema(method.outputs());
88+
operation.insert(
89+
"responses".to_string(),
90+
json!({
91+
"200": {
92+
"description": "Successful response",
93+
"content": {
94+
"application/json": {
95+
"schema": output_schema
96+
},
97+
"application/json-seq": {
98+
"description": "Streaming response using the varlink 'more' flag. Each reply is encoded as an RFC 7464 JSON text sequence (RS 0x1E + JSON + LF). Request this format via Accept: application/json-seq.",
99+
"schema": output_schema
100+
}
101+
}
102+
}
103+
}),
104+
);
105+
106+
let path_item = json!({ "post": Value::Object(operation) });
107+
paths.insert(path, path_item);
108+
}
109+
110+
let mut schemas = serde_json::Map::new();
111+
112+
for custom_type in iface.custom_types() {
113+
let (mut schema, desc) = match custom_type {
114+
CustomType::Object(obj) => (
115+
fields_to_schema(obj.fields()),
116+
comments_to_string(obj.comments()),
117+
),
118+
CustomType::Enum(e) => {
119+
let names: Vec<&str> = e.variants().map(|v| v.name()).collect();
120+
(
121+
json!({"type": "string", "enum": names}),
122+
comments_to_string(e.comments()),
123+
)
124+
}
125+
};
126+
if let Some(desc) = desc {
127+
if let Value::Object(ref mut map) = schema {
128+
map.insert("description".to_string(), json!(desc));
129+
}
130+
}
131+
schemas.insert(custom_type.name().to_string(), schema);
132+
}
133+
134+
for error in iface.errors() {
135+
let mut schema = fields_to_schema(error.fields());
136+
if let Some(desc) = comments_to_string(error.comments()) {
137+
if let Value::Object(ref mut map) = schema {
138+
map.insert("description".to_string(), json!(desc));
139+
}
140+
}
141+
schemas.insert(error.name().to_string(), schema);
142+
}
143+
144+
let mut doc = json!({
145+
"openapi": "3.1.0",
146+
"info": {
147+
"title": iface.name(),
148+
"version": "0.0.0",
149+
},
150+
"paths": paths,
151+
});
152+
153+
if let Some(desc) = comments_to_string(iface.comments()) {
154+
if let Value::Object(ref mut info_obj) = doc["info"] {
155+
info_obj.insert("description".to_string(), json!(desc));
156+
}
157+
}
158+
159+
if !schemas.is_empty() {
160+
if let Value::Object(ref mut doc_obj) = doc {
161+
doc_obj.insert("components".to_string(), json!({ "schemas": schemas }));
162+
}
163+
}
164+
165+
doc
166+
}

src/bin/varlink-httpd/tests.rs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,31 @@ async fn test_varlink_unix_sockets_in_skips_dangling_symlinks() {
518518
assert_eq!(sockets, vec!["io.systemd.Hostname"]);
519519
}
520520

521+
#[test_with::path(/run/systemd/io.systemd.Hostname)]
522+
#[tokio::test]
523+
async fn test_integration_real_systemd_openapi_get() {
524+
let (server, local_addr) = run_test_server("/run/systemd").await;
525+
defer! {
526+
server.abort();
527+
};
528+
529+
let client = Client::new();
530+
let res = client
531+
.get(format!(
532+
"http://{}/openapi/io.systemd.Hostname/io.systemd.Hostname",
533+
local_addr,
534+
))
535+
.send()
536+
.await
537+
.expect("failed to get openapi from test server");
538+
assert_eq!(res.status(), 200);
539+
let body: Value = res.json().await.expect("openapi body invalid");
540+
assert_eq!(body["openapi"], "3.1.0");
541+
assert!(body.get("paths").is_some(), "missing 'paths' key");
542+
assert!(body.get("info").is_some(), "missing 'info' key");
543+
assert_eq!(body["info"]["title"], "io.systemd.Hostname");
544+
}
545+
521546
#[test_with::path(/run/systemd/io.systemd.Hostname)]
522547
#[tokio::test]
523548
async fn test_ws_hostname_describe() {
@@ -1815,3 +1840,138 @@ mod sshauth_tests {
18151840
assert_eq!(body["Hostname"], expected_hostname);
18161841
}
18171842
} // mod sshauth_tests
1843+
1844+
// A varlink IDL that exercises all type features: methods with typed
1845+
// input/output, a struct typedef, an enum typedef, an error with
1846+
// parameters, arrays, dicts, optionals, and doc strings.
1847+
const TEST_IDL: &str = "\
1848+
# A test interface
1849+
interface com.example.test
1850+
1851+
# Status values
1852+
type Status (enabled: bool, tag: ?string)
1853+
1854+
# Priority levels
1855+
type Priority (low, medium, high)
1856+
1857+
# Item not found
1858+
error ItemNotFound (id: int)
1859+
1860+
# Get an item by id
1861+
method GetItem(
1862+
# The item identifier
1863+
id: int,
1864+
options: ?object
1865+
) -> (
1866+
name: string,
1867+
score: float,
1868+
status: Status,
1869+
tags: []string,
1870+
metadata: [string]int,
1871+
mode: (on, off, auto)
1872+
)
1873+
";
1874+
1875+
#[test]
1876+
fn test_idl_to_openapi() {
1877+
let iface: zlink::idl::Interface = TEST_IDL.try_into().expect("failed to parse test IDL");
1878+
let doc = openapi::idl_to_openapi("com.example.test", &iface);
1879+
1880+
// top-level structure
1881+
assert_eq!(doc["openapi"], "3.1.0");
1882+
assert_eq!(doc["info"]["title"], "com.example.test");
1883+
assert_eq!(doc["info"]["version"], "0.0.0");
1884+
assert_eq!(doc["info"]["description"], "A test interface");
1885+
1886+
// paths - one POST for GetItem
1887+
let path = &doc["paths"]["/call/com.example.test/com.example.test.GetItem"];
1888+
assert!(path.is_object(), "missing path for GetItem");
1889+
let post = &path["post"];
1890+
assert_eq!(post["operationId"], "GetItem");
1891+
assert_eq!(post["description"], "Get an item by id");
1892+
1893+
// request body schema
1894+
let req_schema = &post["requestBody"]["content"]["application/json"]["schema"];
1895+
assert_eq!(req_schema["type"], "object");
1896+
assert_eq!(req_schema["properties"]["id"]["type"], "integer");
1897+
assert_eq!(req_schema["properties"]["id"]["format"], "int64");
1898+
assert_eq!(
1899+
req_schema["properties"]["id"]["description"],
1900+
"The item identifier"
1901+
);
1902+
assert_eq!(req_schema["properties"]["options"]["type"], "object");
1903+
// "id" is required, "options" is optional
1904+
let required: Vec<&str> = req_schema["required"]
1905+
.as_array()
1906+
.unwrap()
1907+
.iter()
1908+
.map(|v| v.as_str().unwrap())
1909+
.collect();
1910+
assert!(required.contains(&"id"));
1911+
assert!(!required.contains(&"options"));
1912+
1913+
// response schema
1914+
let resp_content = &post["responses"]["200"]["content"];
1915+
assert!(
1916+
resp_content["application/json-seq"]["description"]
1917+
.as_str()
1918+
.unwrap()
1919+
.contains("RFC 7464"),
1920+
"json-seq response should document RFC 7464 streaming"
1921+
);
1922+
assert_eq!(
1923+
resp_content["application/json-seq"]["schema"], resp_content["application/json"]["schema"],
1924+
"json-seq and json schemas should match"
1925+
);
1926+
let resp_schema = &resp_content["application/json"]["schema"];
1927+
assert_eq!(resp_schema["properties"]["name"]["type"], "string");
1928+
assert_eq!(resp_schema["properties"]["score"]["type"], "number");
1929+
assert_eq!(
1930+
resp_schema["properties"]["status"]["$ref"],
1931+
"#/components/schemas/Status"
1932+
);
1933+
// array type
1934+
assert_eq!(resp_schema["properties"]["tags"]["type"], "array");
1935+
assert_eq!(resp_schema["properties"]["tags"]["items"]["type"], "string");
1936+
// dict type
1937+
assert_eq!(resp_schema["properties"]["metadata"]["type"], "object");
1938+
assert_eq!(
1939+
resp_schema["properties"]["metadata"]["additionalProperties"]["type"],
1940+
"integer"
1941+
);
1942+
// inline enum type
1943+
assert_eq!(resp_schema["properties"]["mode"]["type"], "string");
1944+
assert_eq!(
1945+
resp_schema["properties"]["mode"]["enum"],
1946+
json!(["on", "off", "auto"])
1947+
);
1948+
1949+
// components/schemas - struct typedef
1950+
let status_schema = &doc["components"]["schemas"]["Status"];
1951+
assert_eq!(status_schema["type"], "object");
1952+
assert_eq!(status_schema["description"], "Status values");
1953+
assert_eq!(status_schema["properties"]["enabled"]["type"], "boolean");
1954+
assert_eq!(status_schema["properties"]["tag"]["type"], "string");
1955+
// "enabled" required, "tag" optional
1956+
let status_required: Vec<&str> = status_schema["required"]
1957+
.as_array()
1958+
.unwrap()
1959+
.iter()
1960+
.map(|v| v.as_str().unwrap())
1961+
.collect();
1962+
assert!(status_required.contains(&"enabled"));
1963+
assert!(!status_required.contains(&"tag"));
1964+
1965+
// components/schemas - enum typedef
1966+
let priority_schema = &doc["components"]["schemas"]["Priority"];
1967+
assert_eq!(priority_schema["type"], "string");
1968+
assert_eq!(priority_schema["description"], "Priority levels");
1969+
assert_eq!(priority_schema["enum"], json!(["low", "medium", "high"]));
1970+
1971+
// components/schemas - error
1972+
let error_schema = &doc["components"]["schemas"]["ItemNotFound"];
1973+
assert_eq!(error_schema["type"], "object");
1974+
assert_eq!(error_schema["description"], "Item not found");
1975+
assert_eq!(error_schema["properties"]["id"]["type"], "integer");
1976+
assert_eq!(error_schema["properties"]["id"]["format"], "int64");
1977+
}

0 commit comments

Comments
 (0)