faucet-source-xml
A config-driven XML / SOAP API source for the faucet-stream ecosystem. It fetches an XML (or SOAP) HTTP endpoint, converts the response to JSON, pulls the repeating record element out by a dot-separated element path, and streams the records page-by-page into any faucet-stream sink — a file, a database, a warehouse, a queue — with one declarative config and no glue code.
Reach for it when the upstream system only speaks XML or SOAP: legacy enterprise web services, RSS/Atom-style feeds, government data portals, or any HTTP endpoint that returns application/xml. The parser is event-driven (streaming quick-xml), so peak memory stays bounded by batch_size even for large documents.
Feature highlights
- Automatic XML → JSON conversion — every response is parsed into a
serde_json::Valuetree; attributes, text nodes, and repeated elements all map to predictable JSON shapes. - Element-path record extraction —
records_element_pathwalks a dot-separated path (e.g.Envelope.Body.GetUsersResponse.Users.User) to the repeating element and emits one record per match. A single element collapses to one record; a repeated element fans out to many. - First-class SOAP — a
soap:block assembles the envelope, injects the version-correct headers (SOAPAction for 1.1,Content-Typeaction param for 1.2), resolvesrecords_element_pathrelative toEnvelope.Body, and surfaces SOAP<Fault>responses as errors. Or drop down to the raw-envelope path (method: POST+body) any time. - Two pagination styles — page-number and offset/limit, each with a built-in loop guard so a misbehaving endpoint can't spin forever;
max_pagesis a hard cap across both. - Pluggable authentication —
none,bearer,basic, or fully custom headers — inline or via a shared provider from the CLIauth:catalog (OAuth2 token reuse across matrix rows). - Retry with backoff — transient HTTP failures (5xx / connection resets) are retried up to 3 attempts with exponential backoff (base 500 ms) via the shared
faucet_core::execute_with_retry. - Bounded-memory streaming —
Source::stream_pagesaccumulates matched subtrees and yields aStreamPageeverybatch_sizerecords;batch_size: 0drains the whole document into one page. - Client built once — the
reqwestclient is constructed innew()and reused for every request and every page.
Installation
# As a library:
# In the CLI (opt-in connector feature):
Or pull it in through the umbrella crate:
source-xml is not in the CLI default build — enable it explicitly via the feature flag above.
Quick start
# pipeline.yaml — faucet run pipeline.yaml
version: 1
pipeline:
source:
type: xml
config:
base_url: https://api.example.com
path: /users.xml
method: GET
records_element_path: Response.Users.User
sink:
type: jsonl
config:
path: ./users.jsonl
Configuration reference
Core
| Field | Type | Default | Description |
|---|---|---|---|
base_url |
string | — (required) | Base URL of the API. |
path |
string | — (required) | Request path appended to base_url. |
method |
string | GET |
HTTP method. Use POST for SOAP. |
body |
string | (unset) | Optional raw request body — a hand-written SOAP envelope for POST. Mutually exclusive with soap.body_inner. |
soap |
SoapConfig |
(unset) | First-class SOAP ergonomics — see SOAP. Sugar over the raw body path. |
records_element_path |
string | (unset) | Dot-separated path to the repeating element in the converted JSON (e.g. Envelope.Body.GetUsersResponse.Users.User). When unset, the whole converted document is emitted as one record. With a soap: block and path_relative_to_body (the default), it is resolved relative to Envelope.Body — you write GetUsersResponse.Users.User. |
query_params |
map<string,string> | {} |
Query parameters added to every request (in addition to pagination params). |
Authentication
| Field | Type | Default | Description |
|---|---|---|---|
auth |
XmlAuth (inline {type,config}) or { ref: <name> } |
{ type: none } |
Authentication — see Authentication. |
Pagination
| Field | Type | Default | Description |
|---|---|---|---|
pagination |
XmlPagination |
(unset) | Pagination strategy — see Pagination. When unset, exactly one request is made. |
max_pages |
int | (unset) | Hard cap on pages fetched, across either pagination style. |
Batching
| Field | Type | Default | Description |
|---|---|---|---|
batch_size |
int | 1000 |
Records per emitted StreamPage. The event-driven parser buffers matched subtrees and yields whenever the buffer reaches this size. 0 = no batching: the document is drained end-to-end and the entire result set is emitted in a single page. Validated against MAX_BATCH_SIZE (1,000,000). |
headersexists on the Rust config struct for programmatic use but is#[serde(skip)]— it is not settable from YAML/JSON. UseCustomauth (orquery_params) to attach request headers from config.
Authentication
auth accepts either an inline { type, config } block or a { ref: <name> } pointer to a shared provider declared in the CLI's top-level auth: catalog. Inline variants:
type |
config |
Description |
|---|---|---|
none |
(none) | No authentication (default). |
bearer |
{ token: <string> } |
Authorization: Bearer <token>. |
basic |
{ username, password } |
HTTP Basic authentication. |
custom |
{ headers: { <name>: <value>, … } } |
Arbitrary headers attached to every request — SOAPAction, API keys, content-type overrides, etc. |
# Bearer token (read from the environment)
auth:
type: bearer
config:
token: ${env:FEED_TOKEN}
# HTTP Basic
auth:
type: basic
config:
username: admin
password: ${env:XML_PASSWORD}
# Custom headers — e.g. SOAPAction + an API key
auth:
type: custom
config:
headers:
SOAPAction: "http://example.com/orders/GetOrders"
X-API-Key: ${env:API_KEY}
# Shared provider from the top-level auth: catalog (OAuth2 token reused across rows)
auth:
Examples
REST XML API with page-number pagination
version: 1
pipeline:
source:
type: xml
config:
base_url: https://api.example.com
path: /api/products.xml
records_element_path: Products.Product
pagination:
type: PageNumber
param_name: page
start_page: 1
page_size: 50
page_size_param: per_page
max_pages: 20
sink:
type: jsonl
config:
path: ./products.jsonl
SOAP service with Basic auth (first-class soap: block)
version: 1
pipeline:
source:
type: xml
config:
base_url: https://soap.example.com
path: /ws
method: POST
auth:
type: basic
config:
username: admin
password: ${env:SOAP_PASSWORD}
soap:
version: "1.1" # "1.1" (default) or "1.2"
action: http://example.com/orders/GetOrders
body_inner: '<GetOrders xmlns="http://example.com/orders"/>'
# Resolved relative to Envelope.Body by default:
records_element_path: GetOrdersResponse.Orders.Order
sink:
type: postgres
config:
connection_url: ${env:DATABASE_URL}
table: orders
Offset-paginated feed → MongoDB (Bearer auth)
version: 1
name: xml_to_mongodb
pipeline:
source:
type: xml
config:
base_url: https://feeds.example.com
path: /catalog
method: GET
auth:
type: bearer
config:
token: ${env:FEED_TOKEN}
query_params:
format: xml
records_element_path: catalog.products.product
pagination:
type: Offset
offset_param: offset
limit_param: limit
limit: 250
sink:
type: mongodb
config:
connection_uri: mongodb://localhost:27017
database: warehouse
collection: catalog_items
batch_size: 1000
Runnable copies of the last two shapes live in cli/examples/xml_to_mongodb.yaml and cli/examples/xml_to_s3.yaml.
SOAP
The optional soap: block is sugar over the XML-over-HTTP request/response path — it is not a WSDL client. When present, the source:
- assembles the request envelope from
version(the namespace), any declarednamespaces(prefix → URI on the envelope element), andbody_inner(placed inside<soap:Body>); - injects the version-correct headers, regardless of the
authvariant (so real bearer / basic auth stays free):- 1.1 →
SOAPAction: "<action>"header andContent-Type: text/xml; charset=utf-8; - 1.2 →
Content-Type: application/soap+xml; charset=utf-8; action="<action>"and noSOAPActionheader;
- 1.1 →
- resolves
records_element_pathrelative toEnvelope.Bodywhenpath_relative_to_body(default) — you writeGetUsersResponse.Users.User; - surfaces a SOAP
<Fault>asFaucetError::Sourcewhenfault_as_error(default); set itfalseto emit zero records (logged once) instead.
| Field | Type | Default | Description |
|---|---|---|---|
version |
"1.1" | "1.2" |
"1.1" |
SOAP protocol version — selects the envelope namespace and the header shape. |
action |
string | (unset) | SOAP action. For 1.1 → SOAPAction header; for 1.2 → action param on Content-Type. Omit for actionless operations. |
body_inner |
string | (unset) | XML fragment placed inside <soap:Body> (typically the operation element). Mutually exclusive with the top-level body. |
namespaces |
map<string,string> | {} |
Extra prefix → URI declarations added to the envelope element. The soap prefix is reserved. |
path_relative_to_body |
bool | true |
Auto-prepend Envelope.Body. to records_element_path. Set false to pass a fully-qualified path from the document root. |
fault_as_error |
bool | true |
Raise an error on a SOAP <Fault>; when false, emit zero records instead. |
Load-time validation. Setting both the top-level
bodyandsoap.body_inneris rejected as ambiguous, and asoap:block withmethod: GETis rejected (SOAP is a POST protocol). Both surface asFaucetError::Configbefore any request is made.
Manual alternative — a raw SOAP envelope
The soap: block is optional sugar. You can always hand-write the whole envelope as a raw body and target the fully-qualified path (namespace prefixes preserved). This is the pre-soap: behavior and is unchanged:
version: 1
pipeline:
source:
type: xml
config:
base_url: https://soap.example.com
path: /ws
method: POST
auth:
type: custom
config:
headers:
SOAPAction: '"http://example.com/orders/GetOrders"'
body: |
<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<GetOrders xmlns="http://example.com/orders"/>
</soapenv:Body>
</soapenv:Envelope>
records_element_path: soapenv:Envelope.soapenv:Body.GetOrdersResponse.Orders.Order
sink:
type: jsonl
config:
path: ./orders.jsonl
Pagination
Style (type) |
Fields | Stops when |
|---|---|---|
PageNumber |
param_name, start_page, page_size (optional), page_size_param (optional) |
A page returns zero records, or fewer records than page_size. |
Offset |
offset_param, limit_param, limit |
A page returns fewer records than limit, or a loop is detected. |
max_pages caps the total number of pages for both styles. With no pagination block, exactly one request is made.
Streaming & batching
The source overrides Source::stream_pages. The XML response is parsed with an event-driven quick-xml reader: only the subtree matching records_element_path is materialized, accumulated into a buffer, and yielded as a StreamPage whenever the buffer reaches batch_size. Memory is bounded at one page regardless of document size. With batch_size: 0, the whole document is drained and emitted in a single page — handy for small lookup payloads or for sinks (SQL COPY, BigQuery load jobs) that prefer one large request to many small ones.
This is a one-shot fetch source: each run re-requests the endpoint and has no incremental bookmark / resume support. For incremental loads, encode a watermark in query_params or the request body (e.g. a since=${now.date} param) and drive it from a matrix context or ${now.*} token.
Config loading & schema introspection
Load from YAML/JSON files, environment variables, or a .env file via the helpers in faucet_core::config:
use ;
use XmlStreamConfig;
let config: XmlStreamConfig = load_json?;
let config: XmlStreamConfig = load_env_file?;
Inspect the full JSON Schema with:
Library usage
use Source;
use ;
# async
To wire it into a pipeline, hand the XmlStream to faucet_core::Pipeline (batch) or faucet_core::run_stream (streaming) alongside any Sink. For shared-token reuse across many sources, build a faucet_core::AuthProvider once and inject it with XmlStream::with_auth_provider(provider).
How it works
new()builds thereqwestclient once and stores it on the struct.- Each page issues one request (
base_url + pathwithquery_params+ the pagination param), retried up to 3 times with exponential backoff (base 500 ms) on transient failures viafaucet_core::execute_with_retry. Non-cloneable request bodies are not retried (surfaced asFaucetError::Source). When driven by the CLI, a pipeline-levelresilience:block replaces these built-in retry defaults with one shared policy — XML honors the policy'smax_attempts,base,max,jitter, andretry_onin full. - The response is converted XML → JSON and
records_element_pathis walked to find the repeating element. - Records are buffered and yielded as
StreamPages ofbatch_size; pagination advances until the stop condition ormax_pages.
Lineage dataset URI
<base_url><path> with any embedded credentials stripped — e.g. https://soap.example.com/svc.
Feature flags
This crate has no optional features of its own. Enable it in the CLI / umbrella crate via the source-xml feature.
Troubleshooting / FAQ
| Symptom | Likely cause & fix |
|---|---|
| Zero records returned, no error | records_element_path doesn't match the converted JSON. Inspect the response (the converter maps attributes and text nodes); fix the dot-path to the repeating element, or drop it to emit the whole document as one record. |
| Only one record when you expected many | The path points at a single element rather than the repeated parent's child. Point at the repeating element itself (e.g. …Users.User, not …Users). |
401 / 403 |
Auth missing or wrong. Set the right auth variant; for SOAP endpoints that gate on SOAPAction, add it via custom headers. |
FaucetError::Source: request is not cloneable for retry |
A streaming/non-cloneable request body can't be retried. Pass the SOAP envelope as a plain body string (the default), which is cloneable. |
Persistent 5xx after retries |
The source retries transient failures 3 times with backoff before failing. A persistent 5xx is upstream — check the service; raising your own request timeout won't help. |
| Pagination never stops / fetches too much | Set max_pages as a hard cap. Confirm page_size (PageNumber) or limit (Offset) matches what the API actually returns per page so the "fewer than expected" stop condition fires. |
| Headers set in code aren't sent from YAML | headers is #[serde(skip)] and not configurable from YAML/JSON. Use custom auth (or query_params) to attach headers from config. |
| Malformed XML / parse error | The body isn't well-formed XML (often an HTML error page returned with a 200). Verify the endpoint and that auth/headers select the XML representation (e.g. Accept: application/xml). |
See also
- Connector reference — capability matrix.
- Authentication cookbook — inline vs shared
auth:providers. - Pagination cookbook.
faucet-source-rest— the JSON/REST sibling source.faucet-source-graphql— GraphQL source.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.