Skip to main content

ferrijs_std/web/
form_data.rs

1//! WHATWG `FormData` (spec subset, no deps; multipart serialization
2//! studied from the read-only llrt reference). `append`/`set`/`get`/
3//! `getAll`/`has`/`delete`/`keys`/`values`/`entries`/`forEach`; string,
4//! `Blob` or `File` values. `entries`/`keys`/`values`/`[Symbol.iterator]`
5//! return real live iterators (see [`super::js_iterator`]). A file entry
6//! reads back as a `File` carrying the filename it was stored under, and
7//! appending a `File` supplies that filename without repeating it.
8//! The class holds entries and nothing else: a host that puts a
9//! `FormData` on the wire reads [`FormDataJs::entries_slice`] and
10//! serializes with its own multipart writer, and hands parsed bodies back
11//! through [`FormDataJs::from_entries`].
12
13use rquickjs::atom::PredefinedAtom;
14use rquickjs::function::{Opt, This};
15use rquickjs::{Class, Ctx, Function, Object, Value, class::Trace};
16
17use crate::web::blob_bytes::{blob_parts, file_parts};
18use crate::web::js_iterator::live_iterator;
19
20/// One entry's value: a string, or a file with the name and type it was
21/// stored under. Public because the multipart bridge in the host crate
22/// converts between this and its own wire types.
23#[derive(Clone)]
24pub enum FormEntry {
25  Text(String),
26  File {
27    bytes: Vec<u8>,
28    filename: String,
29    content_type: String,
30  },
31}
32
33#[derive(Trace, Default)]
34#[rquickjs::class(rename = "FormData")]
35pub struct FormDataJs {
36  #[qjs(skip_trace)]
37  entries: Vec<(String, FormEntry)>,
38}
39
40impl FormDataJs {
41  /// Build from entries a host produced (a parsed multipart or
42  /// urlencoded body).
43  #[must_use]
44  pub fn from_entries(entries: Vec<(String, FormEntry)>) -> Self {
45    Self { entries }
46  }
47
48  /// The entries, in insertion order.
49  #[must_use]
50  pub fn entries_slice(&self) -> &[(String, FormEntry)] {
51    &self.entries
52  }
53}
54
55#[allow(unsafe_code)]
56unsafe impl rquickjs::JsLifetime<'_> for FormDataJs {
57  type Changed<'to> = FormDataJs;
58}
59
60impl FormDataJs {
61  fn coerce(value: &Value<'_>, filename: Option<String>) -> FormEntry {
62    // A `File` carries its own name, so `fd.append('f', file)` needs no
63    // explicit filename; an explicit one still wins, per spec.
64    if let Some((bytes, ct, name)) = file_parts(value) {
65      return FormEntry::File {
66        bytes,
67        filename: filename.unwrap_or(name),
68        content_type: if ct.is_empty() {
69          "application/octet-stream".to_string()
70        } else {
71          ct
72        },
73      };
74    }
75    if let Some((bytes, ct)) = blob_parts(value) {
76      return FormEntry::File {
77        bytes,
78        filename: filename.unwrap_or_else(|| "blob".to_string()),
79        content_type: if ct.is_empty() {
80          "application/octet-stream".to_string()
81        } else {
82          ct
83        },
84      };
85    }
86    let s = value
87      .as_string()
88      .and_then(|s| s.to_string().ok())
89      .or_else(|| value.as_number().map(|n| n.to_string()))
90      .or_else(|| value.as_bool().map(|b| b.to_string()))
91      .unwrap_or_default();
92    FormEntry::Text(s)
93  }
94
95  /// Spec: a file entry reads back as a `File` (carrying the filename it
96  /// was stored under), a text entry as a string.
97  fn entry_value<'js>(ctx: &Ctx<'js>, e: &FormEntry) -> rquickjs::Result<Value<'js>> {
98    match e {
99      FormEntry::Text(s) => Ok(rquickjs::String::from_str(ctx.clone(), s)?.into_value()),
100      FormEntry::File {
101        bytes,
102        content_type,
103        filename,
104      } => {
105        let file = Class::instance(
106          ctx.clone(),
107          crate::buffer::File::from_bytes(
108            ctx,
109            bytes.clone(),
110            filename.clone(),
111            Some(content_type.clone()),
112          )?,
113        )?;
114        Ok(file.into_value())
115      },
116    }
117  }
118
119  fn project_entry<'js>(
120    ctx: &Ctx<'js>,
121    parent: &Class<'js, Self>,
122    index: usize,
123  ) -> rquickjs::Result<Option<Value<'js>>> {
124    let Some((name, entry)) = parent.borrow().entries.get(index).cloned() else {
125      return Ok(None);
126    };
127    let pair = rquickjs::Array::new(ctx.clone())?;
128    pair.set(0, rquickjs::String::from_str(ctx.clone(), &name)?)?;
129    pair.set(1, Self::entry_value(ctx, &entry)?)?;
130    Ok(Some(pair.into_value()))
131  }
132
133  fn project_key<'js>(ctx: &Ctx<'js>, parent: &Class<'js, Self>, index: usize) -> rquickjs::Result<Option<Value<'js>>> {
134    let Some((name, _)) = parent.borrow().entries.get(index).cloned() else {
135      return Ok(None);
136    };
137    Ok(Some(rquickjs::String::from_str(ctx.clone(), &name)?.into_value()))
138  }
139
140  fn project_value<'js>(
141    ctx: &Ctx<'js>,
142    parent: &Class<'js, Self>,
143    index: usize,
144  ) -> rquickjs::Result<Option<Value<'js>>> {
145    let Some((_, entry)) = parent.borrow().entries.get(index).cloned() else {
146      return Ok(None);
147    };
148    Self::entry_value(ctx, &entry).map(Some)
149  }
150
151  /// Build from an `application/x-www-form-urlencoded` body: `+` decodes
152  /// to a space and every entry is text (the format cannot carry files).
153  pub fn from_urlencoded(body: &str) -> Self {
154    Self {
155      entries: url::form_urlencoded::parse(body.as_bytes())
156        .map(|(k, v)| (k.into_owned(), FormEntry::Text(v.into_owned())))
157        .collect(),
158    }
159  }
160}
161
162#[rquickjs::methods(rename_all = "camelCase")]
163impl FormDataJs {
164  /// Spec: every platform object carries `Symbol.toStringTag`, so
165  /// `Object.prototype.toString.call(x)` reads `[object FormData]`.
166  #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)]
167  pub fn to_string_tag() -> &'static str {
168    "FormData"
169  }
170
171  #[qjs(constructor)]
172  pub fn new() -> Self {
173    Self::default()
174  }
175
176  #[qjs(rename = "append")]
177  pub fn append(&mut self, name: String, value: Value<'_>, filename: Opt<String>) {
178    self.entries.push((name, Self::coerce(&value, filename.0)));
179  }
180
181  #[qjs(rename = "set")]
182  pub fn set(&mut self, name: String, value: Value<'_>, filename: Opt<String>) {
183    let entry = Self::coerce(&value, filename.0);
184    // Spec: replace the FIRST entry of `name` in place and drop the
185    // rest; append if none — order of the first occurrence is kept.
186    if let Some(i) = self.entries.iter().position(|(k, _)| k == &name) {
187      self.entries[i].1 = entry;
188      let mut seen = false;
189      self.entries.retain(|(k, _)| {
190        if k == &name {
191          if seen {
192            return false;
193          }
194          seen = true;
195        }
196        true
197      });
198    } else {
199      self.entries.push((name, entry));
200    }
201  }
202
203  #[qjs(rename = "has")]
204  pub fn has(&self, name: String) -> bool {
205    self.entries.iter().any(|(k, _)| k == &name)
206  }
207
208  #[qjs(rename = "delete")]
209  pub fn delete(&mut self, name: String) {
210    self.entries.retain(|(k, _)| k != &name);
211  }
212
213  #[qjs(rename = "get")]
214  pub fn get<'js>(&self, ctx: Ctx<'js>, name: String) -> rquickjs::Result<Value<'js>> {
215    match self.entries.iter().find(|(k, _)| k == &name) {
216      Some((_, e)) => Self::entry_value(&ctx, e),
217      None => Ok(Value::new_null(ctx)),
218    }
219  }
220
221  #[qjs(rename = "getAll")]
222  pub fn get_all<'js>(&self, ctx: Ctx<'js>, name: String) -> rquickjs::Result<Vec<Value<'js>>> {
223    self
224      .entries
225      .iter()
226      .filter(|(k, _)| k == &name)
227      .map(|(_, e)| Self::entry_value(&ctx, e))
228      .collect()
229  }
230
231  #[qjs(rename = "keys")]
232  pub fn keys<'js>(ctx: Ctx<'js>, this: This<Class<'js, Self>>) -> rquickjs::Result<Object<'js>> {
233    live_iterator(&ctx, this.0, Self::project_key)
234  }
235
236  #[qjs(rename = "values")]
237  pub fn values<'js>(ctx: Ctx<'js>, this: This<Class<'js, Self>>) -> rquickjs::Result<Object<'js>> {
238    live_iterator(&ctx, this.0, Self::project_value)
239  }
240
241  #[qjs(rename = "entries")]
242  pub fn entries<'js>(ctx: Ctx<'js>, this: This<Class<'js, Self>>) -> rquickjs::Result<Object<'js>> {
243    live_iterator(&ctx, this.0, Self::project_entry)
244  }
245
246  #[qjs(rename = PredefinedAtom::SymbolIterator)]
247  pub fn js_iter<'js>(ctx: Ctx<'js>, this: This<Class<'js, Self>>) -> rquickjs::Result<Object<'js>> {
248    live_iterator(&ctx, this.0, Self::project_entry)
249  }
250
251  #[qjs(rename = "forEach")]
252  pub fn for_each<'js>(&self, ctx: Ctx<'js>, cb: Function<'js>) -> rquickjs::Result<()> {
253    for (k, e) in &self.entries {
254      let v = Self::entry_value(&ctx, e)?;
255      cb.call::<_, ()>((v, k.clone()))?;
256    }
257    Ok(())
258  }
259}