implémentation concrète de DeviceDescriptionProvider
This commit is contained in:
79
pmocontrol/src/control_point.rs
Normal file
79
pmocontrol/src/control_point.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::io;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmoupnp::ssdp::SsdpClient;
|
||||
|
||||
use crate::discovery::DiscoveryManager;
|
||||
use crate::provider::HttpXmlDescriptionProvider;
|
||||
use crate::registry::{DeviceRegistry, DeviceUpdate};
|
||||
|
||||
/// Control point minimal :
|
||||
/// - lance un SsdpClient dans un thread,
|
||||
/// - passe les SsdpEvent au DiscoveryManager,
|
||||
/// - applique les DeviceUpdate dans le DeviceRegistry.
|
||||
pub struct ControlPoint {
|
||||
registry: Arc<RwLock<DeviceRegistry>>,
|
||||
}
|
||||
|
||||
impl ControlPoint {
|
||||
/// Crée un ControlPoint et lance le thread de découverte SSDP.
|
||||
///
|
||||
/// `timeout_secs` : timeout HTTP pour la récupération des descriptions UPnP.
|
||||
pub fn spawn(timeout_secs: u64) -> io::Result<Self> {
|
||||
let registry = Arc::new(RwLock::new(DeviceRegistry::new()));
|
||||
|
||||
// SsdpClient
|
||||
let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient
|
||||
|
||||
// Arc utilisé dans le thread
|
||||
let registry_for_thread = Arc::clone(®istry);
|
||||
|
||||
// Thread de découverte
|
||||
thread::spawn(move || {
|
||||
// Provider HTTP+XML et DiscoveryManager VIVENT dans le thread
|
||||
let provider = HttpXmlDescriptionProvider::new(timeout_secs);
|
||||
let mut discovery = DiscoveryManager::new(provider);
|
||||
|
||||
// ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage
|
||||
// pour forcer les devices à répondre rapidement.
|
||||
let search_targets = [
|
||||
"ssdp:all",
|
||||
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"urn:av-openhome-org:device:MediaRenderer:1",
|
||||
"urn:schemas-upnp-org:device:MediaServer:1",
|
||||
];
|
||||
|
||||
for st in &search_targets {
|
||||
if let Err(e) = client.send_msearch(st, 3) {
|
||||
eprintln!("Failed to send M-SEARCH for {}: {}", st, e);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
// La closure passée à run_event_loop capture discovery par mutable borrow
|
||||
// => FnMut, ce que SsdpClient::run_event_loop accepte.
|
||||
client.run_event_loop(move |event| {
|
||||
let updates: Vec<DeviceUpdate> = discovery.handle_ssdp_event(event);
|
||||
|
||||
if updates.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mut reg) = registry_for_thread.write() {
|
||||
for update in updates {
|
||||
reg.apply_update(update);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(Self { registry })
|
||||
}
|
||||
|
||||
/// Accès au DeviceRegistry partagé.
|
||||
pub fn registry(&self) -> Arc<RwLock<DeviceRegistry>> {
|
||||
Arc::clone(&self.registry)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,14 @@ where
|
||||
max_age: u32,
|
||||
updates: &mut Vec<DeviceUpdate>,
|
||||
) {
|
||||
tracing::debug!(
|
||||
"SSDP update: udn={} type={} location={} max_age={}",
|
||||
udn,
|
||||
device_type,
|
||||
location,
|
||||
max_age
|
||||
);
|
||||
|
||||
let endpoint = self
|
||||
.endpoints
|
||||
.entry(udn.clone())
|
||||
@@ -175,18 +183,30 @@ where
|
||||
});
|
||||
|
||||
endpoint.touch(location, server_header, max_age);
|
||||
endpoint.types_seen.insert(device_type.clone());
|
||||
endpoint.types_seen.insert(device_type);
|
||||
|
||||
if is_renderer_type(&device_type) {
|
||||
endpoint.seen_as_renderer = true;
|
||||
if !endpoint.seen_as_renderer {
|
||||
if let Some(info) = self.provider.build_renderer_info(endpoint) {
|
||||
tracing::debug!(
|
||||
"Renderer classified: udn={} friendly_name={} model={}",
|
||||
info.udn,
|
||||
info.friendly_name,
|
||||
info.model_name
|
||||
);
|
||||
endpoint.seen_as_renderer = true;
|
||||
updates.push(DeviceUpdate::RendererOnline(info));
|
||||
}
|
||||
}
|
||||
|
||||
if is_server_type(&device_type) {
|
||||
endpoint.seen_as_server = true;
|
||||
if !endpoint.seen_as_server {
|
||||
if let Some(info) = self.provider.build_server_info(endpoint) {
|
||||
tracing::debug!(
|
||||
"Server classified: udn={} friendly_name={} model={}",
|
||||
info.udn,
|
||||
info.friendly_name,
|
||||
info.model_name
|
||||
);
|
||||
endpoint.seen_as_server = true;
|
||||
updates.push(DeviceUpdate::ServerOnline(info));
|
||||
}
|
||||
}
|
||||
@@ -206,13 +226,3 @@ fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_renderer_type(t: &str) -> bool {
|
||||
let t = t.to_ascii_lowercase();
|
||||
t.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
||||
}
|
||||
|
||||
fn is_server_type(t: &str) -> bool {
|
||||
let t = t.to_ascii_lowercase();
|
||||
t.contains("urn:schemas-upnp-org:device:mediaserver:")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
pub mod control_point;
|
||||
pub mod soap_client;
|
||||
pub mod discovery;
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
pub mod registry;
|
||||
|
||||
pub use control_point::ControlPoint;
|
||||
|
||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||
pub use model::{
|
||||
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
||||
RendererInfo, RendererProtocol,
|
||||
};
|
||||
pub use provider::HttpXmlDescriptionProvider;
|
||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||
|
||||
pub use soap_client::invoke_upnp_action;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
366
pmocontrol/src/provider.rs
Normal file
366
pmocontrol/src/provider.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
use std::io::BufReader;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::discovery::DiscoveredEndpoint;
|
||||
use crate::model::{
|
||||
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
||||
RendererInfo, RendererProtocol,
|
||||
};
|
||||
|
||||
use ureq::Agent;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DescriptionError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Http(#[from] ureq::Error),
|
||||
|
||||
#[error("Failed to read HTTP body: {0}")]
|
||||
HttpIo(#[from] std::io::Error),
|
||||
|
||||
#[error("XML parsing error: {0}")]
|
||||
Xml(#[from] quick_xml::Error),
|
||||
|
||||
#[error("Missing required device element: {0}")]
|
||||
MissingField(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsedDeviceDescription {
|
||||
udn: Option<String>,
|
||||
device_type: Option<String>,
|
||||
friendly_name: Option<String>,
|
||||
manufacturer: Option<String>,
|
||||
model_name: Option<String>,
|
||||
service_types: Vec<String>,
|
||||
}
|
||||
|
||||
impl ParsedDeviceDescription {
|
||||
fn require_fields(self) -> Result<Self, DescriptionError> {
|
||||
if self.device_type.is_none() {
|
||||
return Err(DescriptionError::MissingField("deviceType"));
|
||||
}
|
||||
if self.friendly_name.is_none() {
|
||||
return Err(DescriptionError::MissingField("friendlyName"));
|
||||
}
|
||||
if self.model_name.is_none() {
|
||||
return Err(DescriptionError::MissingField("modelName"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HttpXmlDescriptionProvider {
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl HttpXmlDescriptionProvider {
|
||||
pub fn new(timeout_secs: u64) -> Self {
|
||||
Self { timeout_secs }
|
||||
}
|
||||
|
||||
fn fetch_and_parse(
|
||||
&self,
|
||||
endpoint: &DiscoveredEndpoint,
|
||||
) -> Result<ParsedDeviceDescription, DescriptionError> {
|
||||
debug!(
|
||||
"Fetching description for {} at {}",
|
||||
endpoint.udn, endpoint.location
|
||||
);
|
||||
|
||||
let config = Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(self.timeout_secs)))
|
||||
.build();
|
||||
|
||||
let agent: Agent = config.into();
|
||||
|
||||
let response = agent.get(&endpoint.location).call()?;
|
||||
|
||||
// response: http::Response<ureq::Body>
|
||||
let (_parts, body) = response.into_parts();
|
||||
|
||||
// body.into_reader() -> impl Read + 'static
|
||||
let body_reader = body.into_reader();
|
||||
|
||||
let mut reader = Reader::from_reader(BufReader::new(body_reader));
|
||||
reader.config_mut().trim_text(true);
|
||||
debug!(
|
||||
"Parsing description XML for {} at {}",
|
||||
endpoint.udn, endpoint.location
|
||||
);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut parsed = ParsedDeviceDescription::default();
|
||||
|
||||
let mut in_device = false;
|
||||
let mut in_service = false;
|
||||
let mut current_tag: Option<String> = None;
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf)? {
|
||||
Event::Start(e) => {
|
||||
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
match name.as_str() {
|
||||
"device" => {
|
||||
in_device = true;
|
||||
current_tag = None;
|
||||
}
|
||||
"service" => {
|
||||
if in_device {
|
||||
in_service = true;
|
||||
current_tag = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if in_device {
|
||||
current_tag = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::End(e) => {
|
||||
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
match name.as_str() {
|
||||
"device" => {
|
||||
in_device = false;
|
||||
}
|
||||
"service" => {
|
||||
in_service = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
current_tag = None;
|
||||
}
|
||||
Event::Text(e) => {
|
||||
if in_device {
|
||||
if let Some(tag) = ¤t_tag {
|
||||
// quick-xml ≥ 0.37 : unescape() → decode()
|
||||
let text = e
|
||||
.decode() // Result<Cow<'_, str>, EncodingError>
|
||||
.map_err(XmlError::Encoding)? // -> quick_xml::Error, donc DescriptionError::Xml via #[from]
|
||||
.into_owned(); // String
|
||||
|
||||
match tag.as_str() {
|
||||
"UDN" => {
|
||||
parsed.udn = Some(text);
|
||||
}
|
||||
"deviceType" => {
|
||||
parsed.device_type = Some(text);
|
||||
}
|
||||
"friendlyName" => {
|
||||
parsed.friendly_name = Some(text);
|
||||
}
|
||||
"manufacturer" => {
|
||||
parsed.manufacturer = Some(text);
|
||||
}
|
||||
"modelName" => {
|
||||
parsed.model_name = Some(text);
|
||||
}
|
||||
"serviceType" if in_service => {
|
||||
parsed.service_types.push(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
parsed.require_fields()
|
||||
}
|
||||
|
||||
fn build_renderer(
|
||||
&self,
|
||||
endpoint: &DiscoveredEndpoint,
|
||||
parsed: &ParsedDeviceDescription,
|
||||
) -> Option<RendererInfo> {
|
||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
||||
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
||||
&& !device_type.contains("urn:av-openhome-org:device:mediarenderer:")
|
||||
&& !device_type.contains("urn:av-openhome-org:device:source:")
|
||||
{
|
||||
debug!(
|
||||
"build_renderer: ignoring deviceType for {}: {}",
|
||||
endpoint.udn, device_type
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw_udn = parsed
|
||||
.udn
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| endpoint.udn.as_str());
|
||||
let udn = raw_udn.to_ascii_lowercase();
|
||||
let caps = detect_renderer_capabilities(&parsed.service_types);
|
||||
let protocol = detect_renderer_protocol(&caps);
|
||||
let now = SystemTime::now();
|
||||
|
||||
Some(RendererInfo {
|
||||
id: RendererId(udn.clone()),
|
||||
udn,
|
||||
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
||||
model_name: parsed.model_name.clone().unwrap_or_default(),
|
||||
manufacturer: parsed.manufacturer.clone().unwrap_or_default(),
|
||||
protocol,
|
||||
capabilities: caps,
|
||||
location: endpoint.location.clone(),
|
||||
server_header: endpoint.server_header.clone(),
|
||||
online: true,
|
||||
last_seen: now,
|
||||
max_age: endpoint.max_age,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_server(
|
||||
&self,
|
||||
endpoint: &DiscoveredEndpoint,
|
||||
parsed: &ParsedDeviceDescription,
|
||||
) -> Option<MediaServerInfo> {
|
||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
||||
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw_udn = parsed
|
||||
.udn
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| endpoint.udn.as_str());
|
||||
let udn = raw_udn.to_ascii_lowercase();
|
||||
let caps = detect_server_capabilities(&parsed.service_types);
|
||||
let now = SystemTime::now();
|
||||
|
||||
Some(MediaServerInfo {
|
||||
id: MediaServerId(udn.clone()),
|
||||
udn,
|
||||
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
||||
model_name: parsed.model_name.clone().unwrap_or_default(),
|
||||
manufacturer: parsed.manufacturer.clone().unwrap_or_default(),
|
||||
capabilities: caps,
|
||||
location: endpoint.location.clone(),
|
||||
server_header: endpoint.server_header.clone(),
|
||||
online: true,
|
||||
last_seen: now,
|
||||
max_age: endpoint.max_age,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_renderer_capabilities(service_types: &[String]) -> RendererCapabilities {
|
||||
let mut caps = RendererCapabilities::default();
|
||||
|
||||
for st in service_types {
|
||||
let lower = st.to_ascii_lowercase();
|
||||
|
||||
if lower.contains("urn:schemas-upnp-org:service:avtransport:") {
|
||||
caps.has_avtransport = true;
|
||||
}
|
||||
if lower.contains("urn:schemas-upnp-org:service:renderingcontrol:") {
|
||||
caps.has_rendering_control = true;
|
||||
}
|
||||
if lower.contains("urn:schemas-upnp-org:service:connectionmanager:") {
|
||||
caps.has_connection_manager = true;
|
||||
}
|
||||
if lower.contains("urn:av-openhome-org:service:playlist:") {
|
||||
caps.has_oh_playlist = true;
|
||||
}
|
||||
if lower.contains("urn:av-openhome-org:service:volume:") {
|
||||
caps.has_oh_volume = true;
|
||||
}
|
||||
if lower.contains("urn:av-openhome-org:service:info:") {
|
||||
caps.has_oh_info = true;
|
||||
}
|
||||
if lower.contains("urn:av-openhome-org:service:time:") {
|
||||
caps.has_oh_time = true;
|
||||
}
|
||||
if lower.contains("urn:av-openhome-org:service:radio:") {
|
||||
caps.has_oh_radio = true;
|
||||
}
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
|
||||
fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
|
||||
let has_upnp_av =
|
||||
caps.has_avtransport || caps.has_rendering_control || caps.has_connection_manager;
|
||||
let has_openhome = caps.has_oh_playlist
|
||||
|| caps.has_oh_volume
|
||||
|| caps.has_oh_info
|
||||
|| caps.has_oh_time
|
||||
|| caps.has_oh_radio;
|
||||
|
||||
match (has_upnp_av, has_openhome) {
|
||||
(true, true) => RendererProtocol::Hybrid,
|
||||
(true, false) => RendererProtocol::UpnpAvOnly,
|
||||
(false, true) => RendererProtocol::OpenHomeOnly,
|
||||
(false, false) => RendererProtocol::UpnpAvOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_server_capabilities(service_types: &[String]) -> MediaServerCapabilities {
|
||||
let mut caps = MediaServerCapabilities::default();
|
||||
|
||||
for st in service_types {
|
||||
let lower = st.to_ascii_lowercase();
|
||||
if lower.contains("urn:schemas-upnp-org:service:contentdirectory:") {
|
||||
caps.has_content_directory = true;
|
||||
}
|
||||
if lower.contains("urn:schemas-upnp-org:service:connectionmanager:") {
|
||||
caps.has_connection_manager = true;
|
||||
}
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
|
||||
use crate::discovery::DeviceDescriptionProvider;
|
||||
|
||||
impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
|
||||
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo> {
|
||||
match self.fetch_and_parse(endpoint) {
|
||||
Ok(parsed) => {
|
||||
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
||||
debug!(
|
||||
"Renderer description OK for {} at {} (deviceType={})",
|
||||
endpoint.udn, endpoint.location, device_type
|
||||
);
|
||||
self.build_renderer(endpoint, &parsed)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to fetch/parse renderer description for {} at {}: {}",
|
||||
endpoint.udn, endpoint.location, err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<MediaServerInfo> {
|
||||
match self.fetch_and_parse(endpoint) {
|
||||
Ok(parsed) => {
|
||||
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
||||
debug!(
|
||||
"Server description OK for {} at {} (deviceType={})",
|
||||
endpoint.udn, endpoint.location, device_type
|
||||
);
|
||||
self.build_server(endpoint, &parsed)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to fetch/parse server description for {} at {}: {}",
|
||||
endpoint.udn, endpoint.location, err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
pmocontrol/src/soap_client.rs
Normal file
83
pmocontrol/src/soap_client.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use anyhow::{Context, Result};
|
||||
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
|
||||
use ureq::Agent;
|
||||
|
||||
/// Result of a SOAP call:
|
||||
/// - HTTP status code
|
||||
/// - raw XML body (always)
|
||||
/// - parsed SOAP envelope if parsing succeeded
|
||||
pub struct SoapCallResult {
|
||||
pub status: ureq::http::StatusCode,
|
||||
pub raw_body: String,
|
||||
pub envelope: Option<SoapEnvelope>,
|
||||
}
|
||||
|
||||
/// Invoke a UPnP SOAP action on a control URL.
|
||||
///
|
||||
/// - `control_url`: full HTTP URL of the service control endpoint
|
||||
/// - `service_type`: service URN, e.g. "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
/// - `action`: action name, e.g. "GetTransportInfo"
|
||||
/// - `args`: list of (name, value) pairs, e.g. &[("InstanceID", "0")]
|
||||
pub fn invoke_upnp_action(
|
||||
control_url: &str,
|
||||
service_type: &str,
|
||||
action: &str,
|
||||
args: &[(&str, &str)],
|
||||
) -> Result<SoapCallResult> {
|
||||
// 1. Build SOAP request body using pmoupnp::soap
|
||||
let body_xml = build_soap_request(service_type, action, args)
|
||||
.context("Failed to build SOAP request body")?;
|
||||
|
||||
// 2. Build an Agent config that does NOT treat 4xx/5xx as errors.
|
||||
//
|
||||
// This is crucial: we want to be able to read the body even for
|
||||
// HTTP 500 SOAP Faults, so we must *not* get Error::StatusCode.
|
||||
let config = Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.build();
|
||||
|
||||
let agent: Agent = config.into();
|
||||
|
||||
// 3. Build SOAPAction header: "urn:service#Action"
|
||||
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
|
||||
|
||||
// 4. Send HTTP POST request
|
||||
//
|
||||
// - RequestBuilder::header(...) is the proper 3.x API.
|
||||
// - RequestBuilder::send(...) accepts anything implementing AsSendBody,
|
||||
// including `String`.
|
||||
let mut response = agent
|
||||
.post(control_url)
|
||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||
.header("SOAPAction", &soap_action_header)
|
||||
.send(body_xml)
|
||||
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
// 5. Read full body into a String, regardless of HTTP status code.
|
||||
//
|
||||
// This matches the pattern you already use for description.xml:
|
||||
// response.body_mut().read_to_string(...)
|
||||
let raw_body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.context("Failed to read SOAP response body")?;
|
||||
|
||||
// 6. Try to parse SOAP envelope.
|
||||
//
|
||||
// We *do not* fail the whole call if parsing fails:
|
||||
// - you still get status + raw_body,
|
||||
// - envelope is None if body is not valid SOAP.
|
||||
let envelope = match parse_soap_envelope(raw_body.as_bytes()) {
|
||||
Ok(env) => Some(env),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
Ok(SoapCallResult {
|
||||
status,
|
||||
raw_body,
|
||||
envelope,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user