1#![forbid(unsafe_code)]
3
4use axum::{Router, body::Body};
5use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, header};
6use http_body_util::BodyExt;
7use serde::{Serialize, de::DeserializeOwned};
8use std::{collections::BTreeMap, process::Output};
9use tower::ServiceExt;
10
11mod plugin_conformance;
12
13pub use plugin_conformance::{
14 ADAPTER_CONFORMANCE_PROFILE, ConformanceAssurance, ConformanceDiagnostic, ConformanceStatus,
15 PLUGIN_CONFORMANCE_PROFILE, PluginConformance, PluginConformanceReport,
16 RUNTIME_CONFORMANCE_PROFILE,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct FixtureIdentity {
21 pub namespace: String,
22 pub kind: String,
23 pub ordinal: u64,
24 pub stable_id: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct FixtureError {
29 message: String,
30}
31
32impl std::fmt::Display for FixtureError {
33 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 formatter.write_str(&self.message)
35 }
36}
37
38impl std::error::Error for FixtureError {}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct FixtureSequence {
44 namespace: String,
45 next_ordinal: u64,
46}
47
48impl FixtureSequence {
49 pub fn new(namespace: impl Into<String>) -> Result<Self, FixtureError> {
50 let namespace = namespace.into();
51 validate_fixture_part(&namespace, "fixture namespace")?;
52 Ok(Self {
53 namespace,
54 next_ordinal: 1,
55 })
56 }
57
58 pub fn next(&mut self, kind: &str) -> Result<FixtureIdentity, FixtureError> {
59 validate_fixture_part(kind, "fixture kind")?;
60 let ordinal = self.next_ordinal;
61 let next_ordinal = ordinal.checked_add(1).ok_or_else(|| FixtureError {
62 message: "fixture sequence exhausted its ordinal range".into(),
63 })?;
64 let identity = FixtureIdentity {
65 namespace: self.namespace.clone(),
66 kind: kind.to_owned(),
67 ordinal,
68 stable_id: format!("{}:{kind}:{ordinal:08}", self.namespace),
69 };
70 self.next_ordinal = next_ordinal;
71 Ok(identity)
72 }
73
74 pub fn build<T>(
75 &mut self,
76 kind: &str,
77 builder: impl FnOnce(FixtureIdentity) -> T,
78 ) -> Result<T, FixtureError> {
79 self.next(kind).map(builder)
80 }
81}
82
83fn validate_fixture_part(value: &str, label: &str) -> Result<(), FixtureError> {
84 let valid = !value.is_empty()
85 && value.len() <= 64
86 && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
87 && value
88 .as_bytes()
89 .last()
90 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
91 && value
92 .bytes()
93 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
94 && !value.contains("--");
95 if valid {
96 Ok(())
97 } else {
98 Err(FixtureError {
99 message: format!(
100 "{label} must be a lowercase kebab-case identifier of at most 64 bytes"
101 ),
102 })
103 }
104}
105
106#[derive(Debug, Clone)]
107pub struct TestClient {
108 router: Router,
109 default_headers: HeaderMap,
110}
111
112impl TestClient {
113 #[must_use]
114 pub fn new(router: Router) -> Self {
115 Self {
116 router,
117 default_headers: HeaderMap::new(),
118 }
119 }
120
121 #[must_use]
122 pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
123 self.default_headers.insert(name, value);
124 self
125 }
126
127 pub async fn get(&self, uri: &str) -> TestResponse {
128 self.request(Method::GET, uri, HeaderMap::new(), Body::empty())
129 .await
130 }
131
132 pub async fn json<T: Serialize>(&self, method: Method, uri: &str, body: &T) -> TestResponse {
133 let body = serde_json::to_vec(body).expect("test request serialization must succeed");
134 let mut headers = HeaderMap::new();
135 headers.insert(
136 header::CONTENT_TYPE,
137 HeaderValue::from_static("application/json"),
138 );
139 self.request(method, uri, headers, Body::from(body)).await
140 }
141
142 pub async fn request(
143 &self,
144 method: Method,
145 uri: &str,
146 request_headers: HeaderMap,
147 body: Body,
148 ) -> TestResponse {
149 let mut request = Request::builder()
150 .method(method)
151 .uri(uri)
152 .body(body)
153 .expect("valid test request");
154 let mut headers = self.default_headers.clone();
155 headers.extend(request_headers);
156 *request.headers_mut() = headers;
157 let response = self
158 .router
159 .clone()
160 .oneshot(request)
161 .await
162 .expect("Axum Router uses an infallible service error");
163 let status = response.status();
164 let headers = response.headers().clone();
165 let body = response
166 .into_body()
167 .collect()
168 .await
169 .expect("response body collection must succeed")
170 .to_bytes()
171 .to_vec();
172 TestResponse {
173 status,
174 headers,
175 body,
176 }
177 }
178}
179
180#[derive(Debug, Clone)]
181pub struct TestResponse {
182 pub status: StatusCode,
183 pub headers: HeaderMap,
184 pub body: Vec<u8>,
185}
186
187impl TestResponse {
188 pub fn json<T: DeserializeOwned>(&self) -> serde_json::Result<T> {
189 serde_json::from_slice(&self.body)
190 }
191
192 #[must_use]
193 pub fn text(&self) -> String {
194 String::from_utf8_lossy(&self.body).into_owned()
195 }
196
197 pub fn assert_status(&self, expected: StatusCode) {
198 assert_eq!(self.status, expected, "response body: {}", self.text());
199 }
200}
201
202#[derive(Debug, Clone, Serialize)]
203pub struct CommandEvidence {
204 pub command: Vec<String>,
205 pub success: bool,
206 pub exit_code: Option<i32>,
207 pub stdout: String,
208 pub stderr: String,
209 pub environment: BTreeMap<String, String>,
210}
211
212impl CommandEvidence {
213 #[must_use]
214 pub fn from_output(command: Vec<String>, output: &Output) -> Self {
215 Self {
216 command,
217 success: output.status.success(),
218 exit_code: output.status.code(),
219 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
220 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
221 environment: BTreeMap::new(),
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use axum::{
230 extract::Json,
231 routing::{get, post},
232 };
233
234 #[tokio::test]
235 async fn client_exercises_router_without_a_socket() {
236 let client = TestClient::new(Router::new().route("/health", get(|| async { "ok" })));
237 let response = client.get("/health").await;
238 response.assert_status(StatusCode::OK);
239 assert_eq!(response.text(), "ok");
240 }
241
242 #[tokio::test]
243 async fn json_requests_set_the_content_type() {
244 let router = Router::new().route(
245 "/echo",
246 post(|Json(value): Json<serde_json::Value>| async move { Json(value) }),
247 );
248 let response = TestClient::new(router)
249 .json(Method::POST, "/echo", &serde_json::json!({"ok": true}))
250 .await;
251 response.assert_status(StatusCode::OK);
252 assert_eq!(response.json::<serde_json::Value>().unwrap()["ok"], true);
253 }
254}