1use std::fmt;
16use std::time::Duration;
17
18use anyhow::Context;
19use reqwest::header::LOCATION;
20use reqwest::redirect::Policy;
21use reqwest::{Client as HttpClient, Response, StatusCode, Url};
22use risingwave_common::bail;
23use risingwave_common::util::retry::exponential_backoff;
24use thiserror_ext::AsReport;
25use tokio_retry::RetryIf;
26use tokio_retry::strategy::jitter;
27
28use super::PulsarSchemaInfo;
29use crate::error::ConnectorResult;
30use crate::schema::{AWS_GLUE_SCHEMA_ARN_KEY, SCHEMA_LOCATION_KEY, SCHEMA_REGISTRY_KEY};
31use crate::source::pulsar::topic::Topic;
32use crate::with_options::{Get, GetKeyIter};
33
34const PULSAR_SCHEMA_PREFIX: &str = "schema.pulsar.";
35pub const PULSAR_SCHEMA_URL_KEY: &str = "schema.pulsar.url";
36pub const PULSAR_SCHEMA_AUTH_TOKEN_KEY: &str = "schema.pulsar.auth.token";
37
38const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
39const DEFAULT_RETRY_DELAY_MS: u64 = 100;
40const DEFAULT_MAX_RETRY_DELAY: Duration = Duration::from_secs(3);
41const DEFAULT_MAX_RETRIES: usize = 3;
42const MAX_REDIRECTS: usize = 5;
43
44#[derive(Debug, thiserror::Error)]
45enum RequestError {
46 #[error("failed to send request")]
47 Send(#[source] reqwest::Error),
48 #[error("failed to read response body")]
49 Body(#[source] reqwest::Error),
50 #[error("request returned HTTP status {0}")]
51 Status(StatusCode),
52 #[error("invalid redirect: {0}")]
53 InvalidRedirect(String),
54 #[error("too many redirects")]
55 TooManyRedirects,
56 #[error("failed to parse response")]
57 Json(#[source] serde_json::Error),
58}
59
60impl RequestError {
61 fn is_retryable(&self) -> bool {
62 match self {
63 Self::Send(_) | Self::Body(_) => true,
64 Self::Status(status) => {
65 *status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
66 }
67 Self::InvalidRedirect(_) | Self::TooManyRedirects | Self::Json(_) => false,
68 }
69 }
70}
71
72#[derive(Clone)]
73pub struct PulsarSchemaConfig {
74 url: String,
75 auth_token: Option<String>,
76}
77
78impl fmt::Debug for PulsarSchemaConfig {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.debug_struct("PulsarSchemaConfig")
81 .field("url", &self.url)
82 .field(
83 "auth_token",
84 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
85 )
86 .finish()
87 }
88}
89
90impl PulsarSchemaConfig {
91 pub fn from_options<T: Get + GetKeyIter>(options: &T) -> ConnectorResult<Option<Self>> {
92 if let Some(option) = options.key_iter().find(|option| {
93 option.starts_with(PULSAR_SCHEMA_PREFIX)
94 && !matches!(
95 *option,
96 PULSAR_SCHEMA_URL_KEY | PULSAR_SCHEMA_AUTH_TOKEN_KEY
97 )
98 }) {
99 bail!("unsupported Pulsar schema option `{option}`");
100 }
101
102 let url = options.get(PULSAR_SCHEMA_URL_KEY).cloned();
103 let auth_token = options.get(PULSAR_SCHEMA_AUTH_TOKEN_KEY).cloned();
104
105 let Some(url) = url else {
106 if auth_token.is_some() {
107 bail!("`{PULSAR_SCHEMA_AUTH_TOKEN_KEY}` requires `{PULSAR_SCHEMA_URL_KEY}`");
108 }
109 return Ok(None);
110 };
111 if url.is_empty() {
112 bail!("`{PULSAR_SCHEMA_URL_KEY}` must not be empty");
113 }
114 if auth_token.as_ref().is_some_and(String::is_empty) {
115 bail!("`{PULSAR_SCHEMA_AUTH_TOKEN_KEY}` must not be empty");
116 }
117 if let Some(option) = options.key_iter().find(|option| {
118 matches!(*option, SCHEMA_LOCATION_KEY | AWS_GLUE_SCHEMA_ARN_KEY)
119 || *option == SCHEMA_REGISTRY_KEY
120 || option.starts_with("schema.registry.")
121 }) {
122 bail!("`{option}` cannot be combined with `{PULSAR_SCHEMA_URL_KEY}`");
123 }
124
125 Ok(Some(Self { url, auth_token }))
126 }
127}
128
129pub struct Client {
132 inner: HttpClient,
133 url: Url,
134 auth_token: Option<String>,
135}
136
137impl fmt::Debug for Client {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.debug_struct("Client")
140 .field("url", &self.url)
141 .field(
142 "auth_token",
143 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
144 )
145 .finish_non_exhaustive()
146 }
147}
148
149impl Client {
150 pub fn new(config: &PulsarSchemaConfig) -> ConnectorResult<Self> {
151 let url = Url::parse(&config.url).context("invalid Pulsar schema URL")?;
152 if url.cannot_be_a_base() {
153 bail!("Pulsar schema URL must be a base URL");
154 }
155 if !matches!(url.scheme(), "http" | "https") {
156 bail!("Pulsar schema URL must use HTTP or HTTPS");
157 }
158 if config.auth_token.is_some() && url.scheme() != "https" {
159 bail!("Pulsar schema bearer authentication requires HTTPS");
160 }
161 if !url.username().is_empty() || url.password().is_some() {
162 bail!("Pulsar schema URL must not contain credentials");
163 }
164 if url.query().is_some() || url.fragment().is_some() {
165 bail!("Pulsar schema URL must not contain a query or fragment");
166 }
167
168 let inner = HttpClient::builder()
171 .timeout(DEFAULT_REQUEST_TIMEOUT)
172 .redirect(Policy::none())
173 .build()
174 .context("failed to build Pulsar schema client")?;
175 Ok(Self {
176 inner,
177 url,
178 auth_token: config.auth_token.clone(),
179 })
180 }
181
182 fn build_schema_url(&self, topic: &Topic, version: Option<i64>) -> ConnectorResult<Url> {
183 let mut url = self.url.clone();
184 let mut path = url
185 .path_segments_mut()
186 .map_err(|_| anyhow::anyhow!("Pulsar schema URL must be a base URL"))?;
187 path.pop_if_empty().extend([
188 "admin",
189 "v2",
190 "schemas",
191 topic.tenant.as_str(),
192 topic.namespace.as_str(),
193 topic.topic.as_str(),
194 "schema",
195 ]);
196 if let Some(version) = version {
197 path.push(version.to_string().as_str());
198 }
199 drop(path);
200 Ok(url)
201 }
202
203 fn redirect_url(current_url: &Url, response: &Response) -> Result<Url, RequestError> {
204 let location = response
205 .headers()
206 .get(LOCATION)
207 .ok_or_else(|| RequestError::InvalidRedirect("missing Location header".to_owned()))?
208 .to_str()
209 .map_err(|error| RequestError::InvalidRedirect(error.to_report_string()))?;
210 let redirect_url = current_url
211 .join(location)
212 .map_err(|error| RequestError::InvalidRedirect(error.to_report_string()))?;
213 if !matches!(redirect_url.scheme(), "http" | "https") {
214 return Err(RequestError::InvalidRedirect(format!(
215 "unsupported URL scheme `{}`",
216 redirect_url.scheme()
217 )));
218 }
219 if current_url.scheme() == "https" && redirect_url.scheme() == "http" {
220 return Err(RequestError::InvalidRedirect(
221 "refusing to redirect from HTTPS to HTTP".to_owned(),
222 ));
223 }
224 if !redirect_url.username().is_empty() || redirect_url.password().is_some() {
225 return Err(RequestError::InvalidRedirect(
226 "redirect URL must not contain credentials".to_owned(),
227 ));
228 }
229 Ok(redirect_url)
230 }
231
232 async fn request_schema(&self, url: &Url) -> Result<PulsarSchemaInfo, RequestError> {
233 let mut request_url = url.clone();
234 let mut redirects = 0;
235 loop {
236 let mut request = self.inner.get(request_url.clone());
237 if let Some(token) = self.auth_token.as_ref() {
238 request = request.bearer_auth(token);
239 }
240
241 let response = request.send().await.map_err(RequestError::Send)?;
242 if matches!(
243 response.status(),
244 StatusCode::MOVED_PERMANENTLY
245 | StatusCode::FOUND
246 | StatusCode::SEE_OTHER
247 | StatusCode::TEMPORARY_REDIRECT
248 | StatusCode::PERMANENT_REDIRECT
249 ) {
250 if redirects >= MAX_REDIRECTS {
251 return Err(RequestError::TooManyRedirects);
252 }
253 redirects += 1;
254 request_url = Self::redirect_url(&request_url, &response)?;
255 continue;
256 }
257 if !response.status().is_success() {
258 return Err(RequestError::Status(response.status()));
259 }
260 let body = response.bytes().await.map_err(RequestError::Body)?;
261 return serde_json::from_slice(&body).map_err(RequestError::Json);
262 }
263 }
264
265 pub async fn get_schema(
268 &self,
269 topic: &Topic,
270 version: Option<i64>,
271 ) -> ConnectorResult<PulsarSchemaInfo> {
272 let url = self.build_schema_url(topic, version)?;
273 let retry_strategy = exponential_backoff(
274 Duration::from_millis(DEFAULT_RETRY_DELAY_MS),
275 2,
276 DEFAULT_MAX_RETRY_DELAY,
277 )
278 .take(DEFAULT_MAX_RETRIES)
279 .map(jitter);
280 RetryIf::spawn(
281 retry_strategy,
282 || self.request_schema(&url),
283 |error: &RequestError| {
284 let retryable = error.is_retryable();
285 if retryable {
286 tracing::debug!(error = %error.as_report(), "retrying Pulsar schema request");
287 }
288 retryable
289 },
290 )
291 .await
292 .map_err(|error| {
293 anyhow::Error::new(error)
294 .context(format!("failed to fetch Pulsar schema from {url}"))
295 .into()
296 })
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use std::collections::BTreeMap;
303 #[cfg(not(madsim))]
304 use std::io::{Read, Write};
305 #[cfg(not(madsim))]
306 use std::net::TcpListener;
307 #[cfg(not(madsim))]
308 use std::sync::mpsc;
309 #[cfg(not(madsim))]
310 use std::thread;
311
312 use super::*;
313 use crate::source::pulsar::topic::parse_topic;
314
315 fn topic(topic: &str) -> Topic {
316 parse_topic(topic).unwrap()
317 }
318
319 fn config(url: String, token: Option<&str>) -> PulsarSchemaConfig {
320 let mut options = BTreeMap::from([(PULSAR_SCHEMA_URL_KEY.to_owned(), url)]);
321 if let Some(token) = token {
322 options.insert(PULSAR_SCHEMA_AUTH_TOKEN_KEY.to_owned(), token.to_owned());
323 }
324 PulsarSchemaConfig::from_options(&options).unwrap().unwrap()
325 }
326
327 fn client() -> Client {
328 Client::new(&config("http://localhost:8080".to_owned(), None)).unwrap()
329 }
330
331 #[test]
332 fn schema_url_from_full_topic() {
333 let client = client();
334 assert_eq!(
335 client
336 .build_schema_url(&topic("persistent://tenant/ns/events"), None)
337 .unwrap()
338 .as_str(),
339 "http://localhost:8080/admin/v2/schemas/tenant/ns/events/schema"
340 );
341 assert_eq!(
342 client
343 .build_schema_url(&topic("persistent://tenant/ns/events"), Some(42))
344 .unwrap()
345 .as_str(),
346 "http://localhost:8080/admin/v2/schemas/tenant/ns/events/schema/42"
347 );
348 }
349
350 #[test]
351 fn schema_url_preserves_base_paths_with_or_without_a_trailing_slash() {
352 for base in [
353 "http://localhost:8080/pulsar",
354 "http://localhost:8080/pulsar/",
355 ] {
356 let client = Client::new(&config(base.to_owned(), None)).unwrap();
357 assert_eq!(
358 client
359 .build_schema_url(&topic("events"), Some(42))
360 .unwrap()
361 .as_str(),
362 "http://localhost:8080/pulsar/admin/v2/schemas/public/default/events/schema/42"
363 );
364 }
365 }
366
367 #[test]
368 fn bearer_authentication_requires_https() {
369 let error = Client::new(&config(
370 "http://localhost:8080".to_owned(),
371 Some("test-token"),
372 ))
373 .unwrap_err();
374 assert!(error.to_string().contains("requires HTTPS"));
375 assert!(
376 Client::new(&config(
377 "https://localhost:8443".to_owned(),
378 Some("test-token")
379 ))
380 .is_ok()
381 );
382 }
383
384 #[test]
385 fn schema_url_from_short_and_escaped_topics() {
386 let client = client();
387 assert_eq!(
388 client
389 .build_schema_url(&topic("events"), None)
390 .unwrap()
391 .as_str(),
392 "http://localhost:8080/admin/v2/schemas/public/default/events/schema"
393 );
394 assert_eq!(
395 client
396 .build_schema_url(&topic("persistent://tenant/ns/events?region=us"), None)
397 .unwrap()
398 .as_str(),
399 "http://localhost:8080/admin/v2/schemas/tenant/ns/events%3Fregion=us/schema"
400 );
401 }
402
403 #[test]
404 fn config_is_optional_and_requires_url_for_token() {
405 assert!(
406 PulsarSchemaConfig::from_options(&BTreeMap::<String, String>::new())
407 .unwrap()
408 .is_none()
409 );
410 let options =
411 BTreeMap::from([(PULSAR_SCHEMA_AUTH_TOKEN_KEY.to_owned(), "token".to_owned())]);
412 assert!(PulsarSchemaConfig::from_options(&options).is_err());
413 }
414
415 #[test]
416 fn config_rejects_unknown_and_overlapping_options() {
417 for option in [
418 "schema.pulsar.ca",
419 SCHEMA_REGISTRY_KEY,
420 "schema.registry.username",
421 SCHEMA_LOCATION_KEY,
422 AWS_GLUE_SCHEMA_ARN_KEY,
423 ] {
424 let options = BTreeMap::from([
425 (
426 PULSAR_SCHEMA_URL_KEY.to_owned(),
427 "http://localhost:8080".to_owned(),
428 ),
429 (option.to_owned(), "value".to_owned()),
430 ]);
431 assert!(
432 PulsarSchemaConfig::from_options(&options).is_err(),
433 "expected `{option}` to be rejected"
434 );
435 }
436 }
437
438 #[test]
439 fn schema_auth_is_separate_from_broker_auth() {
440 let options = BTreeMap::from([
441 (
442 PULSAR_SCHEMA_URL_KEY.to_owned(),
443 "http://localhost:8080".to_owned(),
444 ),
445 (
446 PULSAR_SCHEMA_AUTH_TOKEN_KEY.to_owned(),
447 "schema-token".to_owned(),
448 ),
449 ("auth.token".to_owned(), "broker-token".to_owned()),
450 ]);
451 let config = PulsarSchemaConfig::from_options(&options).unwrap().unwrap();
452 assert_eq!(config.auth_token.as_deref(), Some("schema-token"));
453 }
454
455 #[test]
456 fn config_debug_redacts_token() {
457 let config = config("http://localhost:8080".to_owned(), Some("secret-token"));
458 let debug = format!("{config:?}");
459 assert!(!debug.contains("secret-token"));
460 assert!(debug.contains("[REDACTED]"));
461 }
462
463 #[cfg(not(madsim))]
464 fn spawn_http_server(
465 responses: Vec<String>,
466 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
467 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
468 let addr = listener.local_addr().unwrap();
469 let (request_tx, request_rx) = mpsc::channel();
470 let handle = thread::spawn(move || {
471 for response in responses {
472 let (mut stream, _) = listener.accept().unwrap();
473 let mut request = Vec::new();
474 loop {
475 let mut buffer = [0; 1024];
476 let read = stream.read(&mut buffer).unwrap();
477 assert_ne!(read, 0);
478 request.extend_from_slice(&buffer[..read]);
479 if request.windows(4).any(|window| window == b"\r\n\r\n") {
480 break;
481 }
482 }
483 request_tx
484 .send(String::from_utf8(request).unwrap())
485 .unwrap();
486 stream.write_all(response.as_bytes()).unwrap();
487 }
488 });
489 (format!("http://{addr}"), request_rx, handle)
490 }
491
492 #[cfg(not(madsim))]
493 fn response(status: &str, body: &str) -> String {
494 format!(
495 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
496 body.len()
497 )
498 }
499
500 #[cfg(not(madsim))]
501 #[tokio::test]
502 async fn anonymous_cross_origin_redirect_is_followed() {
503 let body = r#"{"version":1,"type":"AVRO","data":"{}"}"#;
504 let (target_url, target_requests, target_handle) =
505 spawn_http_server(vec![response("200 OK", body)]);
506 let redirect = format!(
507 "HTTP/1.1 307 Temporary Redirect\r\nLocation: {target_url}/schema\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
508 );
509 let (admin_url, admin_requests, admin_handle) = spawn_http_server(vec![redirect]);
510 let client = Client::new(&config(admin_url, None)).unwrap();
511
512 let schema = client
513 .get_schema(&topic("tenant/ns/events"), None)
514 .await
515 .unwrap();
516 assert_eq!(schema.version, 1);
517 admin_requests.recv().unwrap();
518 let redirected_request = target_requests.recv().unwrap().to_ascii_lowercase();
519 assert!(redirected_request.starts_with("get /schema "));
520 assert!(!redirected_request.contains("authorization:"));
521 admin_handle.join().unwrap();
522 target_handle.join().unwrap();
523 }
524
525 #[cfg(not(madsim))]
526 #[tokio::test]
527 async fn authenticated_redirects_follow_https_brokers() {
528 let client = Client::new(&config(
529 "https://admin.example:8443".to_owned(),
530 Some("test-token"),
531 ))
532 .unwrap();
533 for (location, allowed) in [
534 ("/schema", true),
535 ("https://admin.example:8443/schema?authoritative=true", true),
536 ("https://other.example:8443/schema?authoritative=true", true),
537 ("https://admin.example:8444/schema", true),
538 ("http://admin.example:8443/schema", false),
539 ("http://other.example:8080/schema", false),
540 ("file:///tmp/schema", false),
541 ("https://user:password@admin.example:8443/schema", false),
542 ("https://user:password@other.example:8443/schema", false),
543 ] {
544 let redirect = format!(
545 "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
546 );
547 let (url, _requests, handle) = spawn_http_server(vec![redirect]);
548 let response = HttpClient::builder()
549 .redirect(Policy::none())
550 .build()
551 .unwrap()
552 .get(url)
553 .send()
554 .await
555 .unwrap();
556 let result = Client::redirect_url(&client.url, &response);
557 assert_eq!(result.is_ok(), allowed, "{location}");
558 if let Err(error) = result {
559 assert!(!error.is_retryable());
560 }
561 handle.join().unwrap();
562 }
563 }
564
565 #[cfg(not(madsim))]
566 #[tokio::test]
567 async fn interrupted_response_body_is_retried() {
568 let body = r#"{"version":2,"type":"AVRO","data":"{}"}"#;
569 let (admin_url, requests, handle) = spawn_http_server(vec![
570 "HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\n{\"version\":2"
571 .to_owned(),
572 response("200 OK", body),
573 ]);
574 let client = Client::new(&config(admin_url, None)).unwrap();
575
576 assert_eq!(
577 client
578 .get_schema(&topic("events"), None)
579 .await
580 .unwrap()
581 .version,
582 2
583 );
584 requests.recv().unwrap();
585 requests.recv().unwrap();
586 assert!(requests.try_recv().is_err());
587 handle.join().unwrap();
588 }
589
590 #[cfg(not(madsim))]
591 #[tokio::test]
592 async fn server_error_is_retried() {
593 let body = r#"{"version":2,"type":"AVRO","data":"{}"}"#;
594 let (admin_url, requests, handle) = spawn_http_server(vec![
595 response("503 Service Unavailable", ""),
596 response("200 OK", body),
597 ]);
598 let client = Client::new(&config(admin_url, None)).unwrap();
599
600 let schema = client
601 .get_schema(&topic("tenant/ns/events"), None)
602 .await
603 .unwrap();
604 assert_eq!(schema.version, 2);
605 requests.recv().unwrap();
606 requests.recv().unwrap();
607 assert!(requests.try_recv().is_err());
608 handle.join().unwrap();
609 }
610
611 #[cfg(not(madsim))]
612 #[tokio::test]
613 async fn client_error_and_malformed_body_are_not_retried() {
614 for response in [response("404 Not Found", ""), response("200 OK", "{")] {
615 let (admin_url, requests, handle) = spawn_http_server(vec![response]);
616 let client = Client::new(&config(admin_url, None)).unwrap();
617
618 client
619 .get_schema(&topic("tenant/ns/events"), None)
620 .await
621 .unwrap_err();
622 requests.recv().unwrap();
623 assert!(requests.try_recv().is_err());
624 handle.join().unwrap();
625 }
626 }
627}