Build: The TheSpaceDevs Source — Pagination
Maps to: Task 3.3. Kind: Build — spec and test names only. No implementation.
Objective
Build SpaceDevsSource (E — a paginated GET that follows the next cursor to the end) and SpaceDevsTransform (T — deeply nested Launch Library 2 JSON into Row::Launch, including the prose description that the RAG arc will later embed). Every test hits a wiremock server, never the live network.
This is the third concrete Source/Transform pair. It is also the first source whose Extract is a loop of requests rather than a single one, and the first to lean on the with_retry middleware built in the companion page.
Scaffold
Create: crates/etl-sources/src/spacedevs.rs; add pub mod spacedevs; and re-exports to crates/etl-sources/src/lib.rs.
Dependencies: no manifest changes — reqwest, async-trait, chrono, serde, serde_json, tokio, and (dev) wiremock + pretty_assertions were all declared when you created etl-sources in Part II.
reqwest,wiremock, andchronoare not on the Rust playground; build and test this in your workspace withcargo test -p etl-sources spacedevs.
Expected result: cargo test -p etl-sources spacedevs → all four tests green.
The spec (givens)
SpaceDevsSource — fields base_url: String and policy: RetryPolicy; constructor new(base_url: impl Into<String>) that stores the base URL and RetryPolicy::default(). It implements Source:
name(&self)returns"spacedevs".extract:- Trim a trailing
/offbase_url. Seed the cursor with{base}/2.2.0/launch/?limit=100&mode=detailed. - Keep a
HashSet<String>of visited URLs. Before fetching each URL,insertit; ifinsertreturnsfalse(already seen), returnEtlError::Parse("pagination cycle at {url}"). - Fetch each page through
with_retry(self.policy, …): inside the closure,reqwest::Client::get(&url).send()(transport error →EtlError::Network); on a non-success status returnclassify_status(status, headers); on success read the body text (resp.text()error →EtlError::Network). - Deserialize the body into a
Page(parse error →EtlError::Parse). Set the cursor topage.next. Push oneRawRecord { source: "spacedevs", payload: <body>, fetched_at: Utc::now() }per page. - Loop until the cursor is
None; return theVec<RawRecord>.
- Trim a trailing
SpaceDevsTransform — a unit struct implementing Transform:
- Deserialize
raw.payloadinto aPage(parse error →EtlError::Parse). - For each launch in
page.results: parsenetasDateTime<Utc>(parse error →EtlError::Parsenaming the bad value); derivepayloadsanddescriptionfrom the optionalmission(a missing mission ⇒ emptypayloads, emptydescription; a mission's absentname⇒ emptypayloads, absentdescription⇒String::new()); takeproviderfromlaunch_service_provider.name(absent ⇒String::default()). Push aRow::Launch(Launch { id, name, net, provider, payloads, description }).
The deserialization structs (given — the LL2 shape you model, ignoring the dozens of fields you do not need):
Page { next: Option<String>, results: Vec<RawLaunch> }
RawLaunch { id: String, name: String, net: String,
launch_service_provider: Option<Provider>, mission: Option<Mission> }
Provider { name: String }
Mission { name: Option<String>, description: Option<String> }
Note next is Option<String> — serde maps JSON null to None, which is exactly the loop's termination signal. And serde ignores every LL2 field you did not declare, so you model only the five you use.
Concepts exercised
- Cursor pagination: the follow-
nextloop and the visited-set cycle guard (Pagination & Followingnext). with_retrywrapping each page fetch (Retry with Backoff).classify_statusmapping a non-2xx response onto the error taxonomy (Part II'shttp.rs).- Deserializing deeply nested, mostly-ignored JSON into a flat
Row::Launch.
Test names to hit
Use a helper that emits one page of JSON with a parameterized next (a null when you pass None), carrying one launch with a nested mission and launch_service_provider:
follows_next_until_null— the core pagination test. Mount two mocks:path("/2.2.0/launch/")returns a page whosenextpoints at{server.uri()}/page2;path("/page2")returns a page withnext: null. PointSpaceDevsSource::new(server.uri())at it; assertextract().awaityields 2RawRecords.rejects_pagination_cycle— the first page'snextpoints back at itself ({server.uri()}/2.2.0/launch/). Assertextract().awaitisErr(EtlError::Parse(_))— the cycle guard, not an infinite loop.http_error_is_network_error— the mock returns500; assertextract().awaitisErr(EtlError::Network(_))(5xx is transient).parses_nested_launch_json— the core T test, no server. HandSpaceDevsTransformaRawRecordwhose payload is one page; assert oneRow::Launchwithprovider == "SpaceX",payloads == ["Starlink G-99"], and adescriptioncontaining the mission prose.
path("/2.2.0/launch/"), a missing trailing slash — wiremock matches nothing and answers 404. classify_status maps that to EtlError::Permanent, and follows_next_until_null fails looking like a broken source when the mock was never hit. When a pagination test fails on an unexpected permanent error, suspect the matcher before the loop.
The build loop (you drive)
- Transform first — it needs no server. Write
parses_nested_launch_jsonagainst a hand-written page JSON. Predict: what shouldpayloadsanddescriptionbe whenmissionisnull? Decide before implementing. follows_next_until_null. Stand up two mocks; watch the loop consume both pages and stop on thenull. Predict what happens if you forget to set the cursor frompage.next(it stops after one page).rejects_pagination_cycle. Point the first page'snextat itself. Confirm the guard fires asEtlError::Parserather than hanging the test.http_error_is_network_error. A500mock; confirm the 5xx maps to transientNetwork.- Run green, commit.
Done when
cargo test -p etl-sources spacedevs is green. SpaceDevsSource and SpaceDevsTransform now satisfy Source and Transform from etl-core, with pagination, retry, and cycle-safety all exercised against a mock. Three sources now feed one uniform Row enum; the orchestrator in Part V will schedule all three without knowing which is which.