Skip to content

Commit fb6ca65

Browse files
author
lif
committed
Generate an API client with Progenitor
- Validate/generate an OpenAPI schema in a test, as done in omicron - Provide an (optional, for now) codegen'd client from the OpenAPI
1 parent 4454201 commit fb6ca65

File tree

8 files changed

+1182
-179
lines changed

8 files changed

+1182
-179
lines changed

bin/propolis-server/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ hex = "0.4.3"
6060
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] }
6161
ring = "0.16"
6262
slog = { version = "2.5", features = [ "max_level_trace", "release_max_level_debug" ] }
63+
expectorate = "1.0.5"
6364

6465
[features]
6566
default = ["dtrace-probes"]

bin/propolis-server/src/lib/server.rs

+22
Original file line numberDiff line numberDiff line change
@@ -882,3 +882,25 @@ pub fn api() -> ApiDescription<DropshotEndpointContext> {
882882

883883
api
884884
}
885+
886+
#[cfg(test)]
887+
mod tests {
888+
#[test]
889+
fn test_propolis_server_openapi() {
890+
let mut buf: Vec<u8> = vec![];
891+
super::api()
892+
.openapi("Oxide Propolis Server API", "0.0.1")
893+
.description(
894+
"API for interacting with the Propolis hypervisor frontend.",
895+
)
896+
.contact_url("https://oxide.computer")
897+
.contact_email("api@oxide.computer")
898+
.write(&mut buf)
899+
.unwrap();
900+
let output = String::from_utf8(buf).unwrap();
901+
expectorate::assert_contents(
902+
"../../openapi/propolis-server.json",
903+
&output,
904+
);
905+
}
906+
}

lib/propolis-client/Cargo.toml

+7
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ slog = { version = "2.5", features = [ "max_level_trace", "release_max_level_deb
1717
thiserror = "1.0"
1818
uuid = { version = "1.0.0", features = [ "serde", "v4" ] }
1919
crucible-client-types = { git = "https://github.com/oxidecomputer/crucible", rev = "bacffd142fc38a01fe255407b0c8d5d0aacfe778" }
20+
progenitor = { git = "https://github.com/oxidecomputer/progenitor", branch = "main", optional = true }
21+
tokio = { version = "1.0", features = [ "net" ], optional = true }
22+
23+
[features]
24+
default = []
25+
generated = ["progenitor", "tokio"]
26+
generated-migration = ["generated"]

lib/propolis-client/src/generated.rs

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2022 Oxide Computer Company
2+
//! Experimental progenitor-generated propolis-server API client.
3+
4+
progenitor::generate_api!(
5+
spec = "../../openapi/propolis-server.json",
6+
interface = Builder,
7+
tags = Separate,
8+
);
File renamed without changes.
+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//! Interface for making API requests to propolis.
2+
//! This should be replaced with a client generated from the OpenAPI spec.
3+
4+
use reqwest::Body;
5+
use reqwest::IntoUrl;
6+
use serde::de::DeserializeOwned;
7+
use slog::{info, o, Logger};
8+
use std::net::SocketAddr;
9+
use thiserror::Error;
10+
use uuid::Uuid;
11+
12+
pub mod api;
13+
14+
/// Errors which may be returend from the Propolis Client.
15+
#[derive(Debug, Error)]
16+
pub enum Error {
17+
#[error("Request failed: {0}")]
18+
Reqwest(#[from] reqwest::Error),
19+
20+
#[error("Bad Status: {0}")]
21+
Status(u16),
22+
}
23+
24+
/// Client-side connection to propolis.
25+
pub struct Client {
26+
client: reqwest::Client,
27+
log: Logger,
28+
address: SocketAddr,
29+
}
30+
31+
// Sends "request", awaits "response", and returns an error on any
32+
// non-success status code.
33+
//
34+
// TODO: Do we want to handle re-directs?
35+
async fn send_and_check_ok(
36+
request: reqwest::RequestBuilder,
37+
) -> Result<reqwest::Response, Error> {
38+
let response = request.send().await.map_err(Error::from)?;
39+
40+
if !response.status().is_success() {
41+
return Err(Error::Status(response.status().as_u16()));
42+
}
43+
44+
Ok(response)
45+
}
46+
47+
// Sends a "request", awaits "response", and parses the body
48+
// into a deserializable type.
49+
async fn send_and_parse_response<T: DeserializeOwned>(
50+
request: reqwest::RequestBuilder,
51+
) -> Result<T, Error> {
52+
send_and_check_ok(request).await?.json().await.map_err(|e| e.into())
53+
}
54+
55+
impl Client {
56+
pub fn new(address: SocketAddr, log: Logger) -> Client {
57+
Client {
58+
client: reqwest::Client::new(),
59+
log: log.new(o!("propolis_client address" => address.to_string())),
60+
address,
61+
}
62+
}
63+
64+
async fn get<T: DeserializeOwned, U: IntoUrl + std::fmt::Display>(
65+
&self,
66+
path: U,
67+
body: Option<Body>,
68+
) -> Result<T, Error> {
69+
info!(self.log, "GET request to {}", path);
70+
let mut request = self.client.get(path);
71+
if let Some(body) = body {
72+
request = request.body(body);
73+
}
74+
75+
send_and_parse_response(request).await
76+
}
77+
78+
async fn put<T: DeserializeOwned, U: IntoUrl + std::fmt::Display>(
79+
&self,
80+
path: U,
81+
body: Option<Body>,
82+
) -> Result<T, Error> {
83+
info!(self.log, "PUT request to {}", path);
84+
let mut request = self.client.put(path);
85+
if let Some(body) = body {
86+
request = request.body(body);
87+
}
88+
89+
send_and_parse_response(request).await
90+
}
91+
92+
async fn post<T: DeserializeOwned, U: IntoUrl + std::fmt::Display>(
93+
&self,
94+
path: U,
95+
body: Option<Body>,
96+
) -> Result<T, Error> {
97+
info!(self.log, "POST request to {}", path);
98+
let mut request = self.client.post(path);
99+
if let Some(body) = body {
100+
request = request.body(body);
101+
}
102+
103+
send_and_parse_response(request).await
104+
}
105+
106+
async fn put_no_response<U: IntoUrl + std::fmt::Display>(
107+
&self,
108+
path: U,
109+
body: Option<Body>,
110+
) -> Result<(), Error> {
111+
info!(self.log, "PUT request to {}", path);
112+
let mut request = self.client.put(path);
113+
if let Some(body) = body {
114+
request = request.body(body);
115+
}
116+
117+
send_and_check_ok(request).await?;
118+
Ok(())
119+
}
120+
121+
/// Ensures that an instance with the specified properties exists.
122+
pub async fn instance_ensure(
123+
&self,
124+
request: &api::InstanceEnsureRequest,
125+
) -> Result<api::InstanceEnsureResponse, Error> {
126+
let path = format!("http://{}/instance", self.address,);
127+
let body = Body::from(serde_json::to_string(&request).unwrap());
128+
self.put(path, Some(body)).await
129+
}
130+
131+
/// Returns information about an instance, by UUID.
132+
pub async fn instance_get(
133+
&self,
134+
) -> Result<api::InstanceGetResponse, Error> {
135+
let path = format!("http://{}/instance", self.address);
136+
self.get(path, None).await
137+
}
138+
139+
/// Long-poll for state changes.
140+
pub async fn instance_state_monitor(
141+
&self,
142+
gen: u64,
143+
) -> Result<api::InstanceStateMonitorResponse, Error> {
144+
let path = format!("http://{}/instance/state-monitor", self.address);
145+
let body = Body::from(
146+
serde_json::to_string(&api::InstanceStateMonitorRequest { gen })
147+
.unwrap(),
148+
);
149+
self.get(path, Some(body)).await
150+
}
151+
152+
/// Puts an instance into a new state.
153+
pub async fn instance_state_put(
154+
&self,
155+
state: api::InstanceStateRequested,
156+
) -> Result<(), Error> {
157+
let path = format!("http://{}/instance/state", self.address);
158+
let body = Body::from(serde_json::to_string(&state).unwrap());
159+
self.put_no_response(path, Some(body)).await
160+
}
161+
162+
/// Get the status of an ongoing migration
163+
pub async fn instance_migrate_status(
164+
&self,
165+
migration_id: Uuid,
166+
) -> Result<api::InstanceMigrateStatusResponse, Error> {
167+
let path = format!("http://{}/instance/migrate/status", self.address);
168+
let body = Body::from(
169+
serde_json::to_string(&api::InstanceMigrateStatusRequest {
170+
migration_id,
171+
})
172+
.unwrap(),
173+
);
174+
self.get(path, Some(body)).await
175+
}
176+
177+
/// Returns the WebSocket URI to an instance's serial console stream.
178+
pub fn instance_serial_console_ws_uri(&self) -> String {
179+
format!("ws://{}/instance/serial", self.address)
180+
}
181+
182+
pub async fn instance_issue_crucible_snapshot_request(
183+
&self,
184+
disk_id: Uuid,
185+
snapshot_id: Uuid,
186+
) -> Result<(), Error> {
187+
let path = format!(
188+
"http://{}/instance/disk/{}/snapshot/{}",
189+
self.address, disk_id, snapshot_id,
190+
);
191+
self.post(path, None).await
192+
}
193+
}

0 commit comments

Comments
 (0)