Call an HTTP API
Day apps fetch through each platform’s own networking stack: NSURLSession on macOS and iOS,
OkHttp on Android, WinHTTP on Windows, the browser’s fetch() on the web, and a bundled
ureq + rustls client on Linux and HarmonyOS. The request inherits what the OS already knows
(system proxies and PAC scripts, VPN routing, Low Data Mode, certificate stores), and the
native targets bundle no TLS code at all. The whole call site is:
day::task(async move {
match day_part_http::fetch_future(Request::get(url)).await {
Ok(resp) => body.set(resp.text().into_owned()),
Err(e) => body.set(format!("error: {e}")),
}
});
Works on: macOS, iOS, Android, Windows, and web via the platform stack; Linux and
HarmonyOS via the Rust fallback (correct HTTPS, but proxy awareness limited to the
http_proxy environment variables); on an unknown target every call returns Unsupported.
On web the blocking entry points return Unsupported; the async forms work in full.
day_part_http::tier() reports the compiled target’s tier.
1. Fetch into UI state
Add the crate, then hold the request’s outcome in signals and let the UI bind them:
[dependencies]
day-part-http = { git = "https://github.com/daybrite/day.git" }
The match goes inside the task: day::task takes a future with Output = (), so an async
block that returns a Result doesn’t compile there; handle both arms and write signals.
Under day::task the future resumes on the UI thread, so those writes are plain set calls:
use day::prelude::*;
use day_part_http::{Request, fetch_future};
fn forecast_row() -> impl Piece {
let readout = Signal::new(String::new());
let loading = Signal::new(false);
column((
button("Fetch").action(move || {
loading.set(true);
day::task(async move {
match fetch_future(Request::get("https://api.example.com/data.json")).await {
Ok(resp) if (200..300).contains(&resp.status) => {
readout.set(resp.text().into_owned());
}
Ok(resp) => readout.set(format!("HTTP {}", resp.status)),
Err(e) => readout.set(format!("error: {e}")),
}
loading.set(false);
});
}),
label(move || if loading.get() { "loading…".to_string() } else { readout.get() }),
))
.spacing(8.0)
}
Two contract points that differ from ureq-style clients:
- 4xx/5xx are
Ok. An HTTP error status is a response (resp.status == 404), not anHttpError, which is why the sample checks the status range. Errors are transport-level only:BadUrl,Timeout,Dns,Connect,Tls,Io,Cancelled,Unsupported. timeoutbounds progress, not the transfer. It covers connecting, awaiting the response head, and idle gaps; a long download that keeps moving is never cut off. Default 30 s.
Response is { status, headers, body } plus text() (lossy UTF-8) and a case-insensitive
header(name) lookup. There is no built-in JSON layer; parse resp.body with serde_json,
an ordinary app dependency and what the crate’s own docs use:
#[derive(serde::Deserialize)]
struct Forecast { temperature: f64 }
let data: Result<Forecast, _> = serde_json::from_slice(&resp.body);
2. Cancel an in-flight request
Tasks are not owned by the scope that spawned them: leaving the page does not stop a running
fetch. To cancel, keep the TaskHandle that day::task returns and call .abort(). Aborting
drops the task’s future, and dropping the FetchFuture is what cancels the platform
request: NSURLSessionTask.cancel on Apple, OkHttp Call.cancel on Android, the fetch’s
AbortController on web. Windows and the Rust fallback can’t cancel mid-flight; they run the
request out on a worker thread and discard the result. Re-tapping below supersedes the
previous request:
let inflight: Rc<Cell<Option<day::TaskHandle>>> = Rc::new(Cell::new(None));
button("Check").action(move || {
if let Some(prev) = inflight.take() {
prev.abort(); // drops the FetchFuture → platform cancel
}
let slot = inflight.clone();
let handle = day::task(async move {
match fetch_future(Request::get(url.get_untracked())).await {
Ok(resp) => readout.set(format!("HTTP {} · {} bytes", resp.status, resp.body.len())),
Err(e) => readout.set(format!("error: {e}")),
}
slot.set(None);
});
inflight.set(Some(handle));
});
Aborting a finished task is a no-op, and a write to a signal whose scope has since been disposed is a silent no-op, so a late completion can’t crash a page the user already left.
3. Load on mount with Resource
For “fetch when this page appears, refetch when an input changes”, day::reactive::Resource
is the scope-tied form: a tracked source feeds an async fetcher, and the result lands in a
Signal<Load<T>>. Unlike day::task, the fetcher returns a Result, and the error becomes
Load::Failed:
use day::reactive::{Load, Resource};
let forecast: Resource<String> = Resource::new(
move || city.get(), // tracked — a city change refetches
|city| async move {
let url = format!("https://api.example.com/wx?city={city}");
let resp = day_part_http::fetch_future(Request::get(url)).await?;
Ok::<_, day_part_http::HttpError>(resp.text().into_owned())
},
);
label(move || forecast.with(|l| match l {
Load::Loading => "…".to_string(),
Load::Ready(s) => s.clone(),
Load::Failed(e) => format!("error: {e}"),
}))
Latest wins: a source change aborts the in-flight fetch (dropping its FetchFuture, the same
cancel rail as above) and a stale completion writes nothing. Scope disposal aborts it too, with no
handle bookkeeping, and forecast.refetch() forces a fresh fetch. Watch the import: the
prelude’s Resource is the bundled-asset handle; the async one is day::reactive::Resource.
4. Other methods, headers, and bodies
Request builds every common shape: get/delete/head(url), and post/put/patch(url, body) taking the body bytes up front. .header(name, value) appends (duplicates allowed,
sent in order), and .allow_expensive(bool) / .allow_constrained(bool) gate cellular and
Low Data Mode use (native on Apple, advisory elsewhere):
let req = Request::post("https://api.example.com/notes", note_json_bytes)
.header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(15));
For large downloads, fetch_to_file(&req, &dest) streams the body straight to disk, never
holding it in memory; fetch_streamed adds per-chunk control (progress, hashing, mid-body
cancel). To cache a response across launches, write resp.body with day-part-fs; see
Store data on device.
Pitfalls
- Don’t block the UI thread.
fetch,fetch_to_file, andfetch_streamedblock their calling thread; run them on your own thread, or use the futures underday::task. On web the blocking calls returnUnsupported; the single browser thread cannot wait. fetch_asynccompletes on a background thread. Never touch signals directly in its callback; capture aSetter(signal.setter()), which hops to the UI thread itself. Underday::task,fetch_futureresumes on the UI thread and needs none of this.- Cleartext
http://is platform policy. Apple’s ATS refuses non-HTTPS URLs without a scoped Info.plist exception (loopback is exempt); Android blocks cleartext app-wide since targetSdk 28, loopback included; scope an exception innetwork_security_config.xml. The Rust fallback enforces no such policy, another reasontier()exists. - The browser adds CORS. On web, cross-origin requests need the server’s opt-in, and
browser-controlled request headers (
Host,Cookie,Origin) are ignored per the fetch spec. Network-level failures surface asHttpError::Io, since browsers hide DNS/TLS detail.
Reference
http — the full Request/Response contract, per-platform
realization, error mapping, and the cancel matrix.
async — day::task, TaskHandle, Resource, and the rules that keep
async at the edges.