vorma 0.86.0-pre.6

Rust-first full-stack web framework: typed views and resources, generated TypeScript contracts, and a Vite-integrated frontend.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! Page and endpoint targets resolved to hrefs. A page target names one of
//! the application's view patterns; each loader at or above the pattern that
//! owns search fields must accept its own fields, and the href carries the
//! fields as the input supplied them. An endpoint target names an endpoint
//! pattern; the redirect's follow-up request must reach an endpoint with the
//! redirecting endpoint's types.

use std::fmt;
use std::sync::Arc;

use http::Method;
use serde_json::{Map, Value};
use vorma_path_wire::{
	DYNAMIC_PARAM_PREFIX, INDEX_SEGMENT, SPLAT_SEGMENT, canonicalize_pattern, encode_path_segment,
};

use crate::outcome::{RedirectingApiEndpoint, ResolveTarget};
use crate::plan::{ApiEndpointNode, ClassifiedRequest, CurrentPlan, Plan, ViewNode};
use crate::result::{ApiEndpointTarget, PageTarget, RedirectStatus};
use crate::search::{SearchShape, serialize_search_params};

/// Turns page targets into hrefs. Every handler context has one as
/// `page_hrefs`, and [`LoadedApp::page_hrefs`](crate::LoadedApp::page_hrefs)
/// returns one for code outside a request, such as host routes and background
/// jobs. Canonical links, `og:url` and hreflang alternates, pagination links,
/// and links in email can use it instead of formatting URLs by hand.
///
/// A [`Redirect`](crate::Redirect) to a [`PageTarget`] and
/// [`PageHrefs::to_href`] resolve the target by the same rules.
#[derive(Clone)]
pub struct PageHrefs {
	resolver: Arc<dyn ResolveTarget>,
}

impl PageHrefs {
	pub(crate) fn new(resolver: Arc<dyn ResolveTarget>) -> Self {
		Self { resolver }
	}

	/// Creates a `PageHrefs` that resolves every page target with
	/// `resolve_page_target`, which returns the href or the reason that the
	/// target is invalid. A test uses it to build a handler context without a
	/// loaded app.
	pub fn from_resolver(
		resolve_page_target: impl Fn(&PageTarget) -> Result<String, String> + Send + Sync + 'static,
	) -> Self {
		Self::new(Arc::new(FunctionResolver(resolve_page_target)))
	}

	/// Returns the page's path, search, and hash as a relative href.
	///
	/// The target's pattern must be a page in the current build, and the
	/// search input must fit the input of every loader at or above the
	/// pattern. Path segments and search parameters get Vorma's encoding, and
	/// the hash gets the URL standard's fragment encoding. The path that the
	/// target builds must reach the target's own page: when another page
	/// pattern or a `GET` API endpoint is more specific for that path, such as
	/// `/users/new` for the target `/users/:id` with `id` set to `new` when
	/// `/users/new` is also a page, the target is invalid. A target that does
	/// not meet these conditions returns an [`HrefError`].
	///
	/// The current build is the build that the request used, for a handler
	/// context, and the build that Vorma most recently planned, for
	/// [`LoadedApp::page_hrefs`](crate::LoadedApp::page_hrefs). A redirect
	/// resolves its target against the build that the request used.
	pub fn to_href(&self, target: &PageTarget) -> Result<String, HrefError> {
		self.resolver
			.resolve_page_target(target)
			.map_err(|problem| HrefError {
				message: format!("the page target {:?} is invalid: {problem}", target.pattern),
			})
	}
}

/// A page-target resolver from a function. It resolves no endpoint target.
struct FunctionResolver<Resolve>(Resolve);

impl<Resolve: Fn(&PageTarget) -> Result<String, String> + Send + Sync> ResolveTarget
	for FunctionResolver<Resolve>
{
	fn resolve_page_target(&self, target: &PageTarget) -> Result<String, String> {
		(self.0)(target)
	}

	fn resolve_api_endpoint_target(
		&self,
		_redirecting: &RedirectingApiEndpoint,
		_target: &ApiEndpointTarget,
		_status: RedirectStatus,
	) -> Result<String, String> {
		Err("a PageHrefs made from a function resolves page targets only".to_owned())
	}
}

impl fmt::Debug for PageHrefs {
	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
		formatter.debug_struct("PageHrefs").finish_non_exhaustive()
	}
}

/// A [`PageTarget`] that cannot become an href: its pattern is not a page in
/// the current build, a parameter is missing, the search input does not fit
/// the loaders at or above the pattern, or the path that the target builds
/// belongs to a different page or to a `GET` API endpoint.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HrefError {
	message: String,
}

impl fmt::Display for HrefError {
	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
		formatter.write_str(&self.message)
	}
}

impl std::error::Error for HrefError {}

impl<State: Send + Sync + 'static> ResolveTarget for CurrentPlan<State> {
	fn resolve_page_target(&self, target: &PageTarget) -> Result<String, String> {
		self.get().resolve_page_target(target)
	}

	fn resolve_api_endpoint_target(
		&self,
		redirecting: &RedirectingApiEndpoint,
		target: &ApiEndpointTarget,
		status: RedirectStatus,
	) -> Result<String, String> {
		self.get()
			.resolve_api_endpoint_target(redirecting, target, status)
	}
}

impl<State: Send + Sync + 'static> ResolveTarget for Plan<State> {
	fn resolve_page_target(&self, target: &PageTarget) -> Result<String, String> {
		let node = self
			.find_view_node(target.pattern)
			.ok_or_else(|| "no view loader or view declares this pattern".to_owned())?;
		let search = resolve_search(node, target)?;
		let parts = HrefParts {
			kind: PatternKind::View,
			pattern: &node.pattern,
			params: &target.params,
			splat_values: &target.splat_values,
			search,
			hash: target.hash.as_deref(),
		};
		let mut href = build_path(&parts)?;
		self.check_path_reaches_view(&href, node)?;
		append_search_and_hash(&mut href, parts);
		Ok(href)
	}

	fn resolve_api_endpoint_target(
		&self,
		redirecting: &RedirectingApiEndpoint,
		target: &ApiEndpointTarget,
		status: RedirectStatus,
	) -> Result<String, String> {
		let follow_up = follow_up_method(status, &redirecting.method);
		let node = self
			.find_api_endpoint_node(&follow_up, target.pattern)
			.ok_or_else(|| {
				format!(
					"the follow-up request uses {follow_up}, and no {follow_up} API endpoint declares this pattern"
				)
			})?;
		let types = &node.declaration.types;
		if types.output != redirecting.types.output {
			return Err(
				"its output type differs from the redirecting endpoint's output type".to_owned(),
			);
		}
		if types.declared_error != redirecting.types.declared_error {
			return Err(
				"its declared error type differs from the redirecting endpoint's declared error type"
					.to_owned(),
			);
		}
		let search = if follow_up == redirecting.method && follow_up != Method::GET {
			if types.input != redirecting.types.input {
				return Err(format!(
					"the follow-up {follow_up} request carries this request's body, and its input type differs from the redirecting endpoint's input type"
				));
			}
			if target.input.is_some() {
				return Err(format!(
					"the follow-up {follow_up} request carries this request's body, so the target takes no search input"
				));
			}
			None
		} else {
			resolve_api_endpoint_search(node, target)?
		};
		let parts = HrefParts {
			kind: PatternKind::ApiEndpoint,
			pattern: node.declaration.pattern,
			params: &target.params,
			splat_values: &target.splat_values,
			search,
			hash: None,
		};
		let mut href = build_path(&parts)?;
		append_search_and_hash(&mut href, parts);
		Ok(href)
	}
}

impl<State> Plan<State> {
	/// Checks that a `GET` request for `path` reaches `node`. A path that
	/// the target's params and splat values build can belong to a more specific pattern, such
	/// as `/users/new` for the target `/users/:id` with `id` set to `new`
	/// when `/users/new` is also a view pattern, or to a `GET` endpoint.
	fn check_path_reaches_view(&self, path: &str, node: &ViewNode<State>) -> Result<(), String> {
		match self.classify(&Method::GET, path) {
			Ok(ClassifiedRequest::View { positions })
				if positions
					.last()
					.is_some_and(|leaf| std::ptr::eq(leaf.node, node)) =>
			{
				Ok(())
			}
			Ok(ClassifiedRequest::View { positions }) => Err(format!(
				"the path {path:?} belongs to the view pattern {:?}",
				positions
					.last()
					.map_or("", |leaf| leaf.node.pattern.as_str())
			)),
			Ok(ClassifiedRequest::ApiEndpoint { node, .. }) => Err(format!(
				"the path {path:?} belongs to the GET API endpoint {:?}",
				node.declaration.pattern
			)),
			Ok(ClassifiedRequest::Unmatched) => {
				Err(format!("the path {path:?} matches no view pattern"))
			}
			Err(error) => Err(format!("the path {path:?} is not a valid path: {error}")),
		}
	}
}

/// The method of the request that follows a redirect, as the Fetch standard
/// defines it: 303 turns any method except `HEAD` into `GET`, 302 turns `POST`
/// into `GET`, and 307 and 308 keep the method. An endpoint's declared method
/// is never `HEAD`, because the `GET` endpoint answers `HEAD` requests.
fn follow_up_method(status: RedirectStatus, method: &Method) -> Method {
	match status {
		RedirectStatus::SeeOther => Method::GET,
		RedirectStatus::Found if method == Method::POST => Method::GET,
		RedirectStatus::Found
		| RedirectStatus::TemporaryRedirect
		| RedirectStatus::PermanentRedirect => method.clone(),
	}
}

/// A follow-up `GET` request's search input, which must fit the target
/// endpoint's input type.
fn resolve_api_endpoint_search<State>(
	node: &ApiEndpointNode<State>,
	target: &ApiEndpointTarget,
) -> Result<Option<Map<String, Value>>, String> {
	let input = match &target.input {
		None => None,
		Some(Ok(input)) => Some(input),
		Some(Err(error)) => return Err(format!("the search input cannot be serialized: {error}")),
	};
	let Some(shape) = &node.search else {
		return match input {
			None => Ok(None),
			Some(_) => Err("the endpoint takes no search input".to_owned()),
		};
	};
	let empty = Map::new();
	let object = match input {
		// An omitted search is an empty one; the input type decides whether
		// that fits it.
		None => &empty,
		Some(Value::Object(object)) => object,
		Some(_) => return Err("the search input is not an object".to_owned()),
	};
	let projected: Map<String, Value> = field_names(shape)
		.filter_map(|name| {
			object
				.get(name)
				.map(|value| (name.to_owned(), value.clone()))
		})
		.collect();
	if !(node.declaration.is_search_input_valid_fn)(Value::Object(projected.clone())) {
		return Err("the search input does not fit the endpoint's input type".to_owned());
	}
	Ok(Some(projected))
}

fn field_names(shape: &SearchShape) -> impl Iterator<Item = &str> {
	let fields = match shape {
		SearchShape::Object { fields, .. } => fields.as_slice(),
		_ => &[],
	};
	fields.iter().map(|field| field.name.as_str())
}

/// The search fields that the pattern's owners accept, in owner order.
fn resolve_search<State>(
	node: &ViewNode<State>,
	target: &PageTarget,
) -> Result<Option<Map<String, Value>>, String> {
	let input = match &target.input {
		None => None,
		Some(Ok(input)) => Some(input),
		Some(Err(error)) => return Err(format!("the search input cannot be serialized: {error}")),
	};
	if node.search_owners.is_empty() {
		return match input {
			None => Ok(None),
			Some(_) => Err("no loader at or above this pattern declares search input".to_owned()),
		};
	}
	let empty = Map::new();
	let object = match input {
		// An omitted search is an empty one; each owner decides whether that
		// satisfies it.
		None => &empty,
		Some(Value::Object(object)) => object,
		Some(_) => return Err("the search input is not an object".to_owned()),
	};
	let mut search = Map::new();
	for owner in &node.search_owners {
		let Some(shape) = &owner.search else {
			continue;
		};
		let projected: Map<String, Value> = field_names(shape)
			.filter_map(|name| {
				object
					.get(name)
					.map(|value| (name.to_owned(), value.clone()))
			})
			.collect();
		if !(owner.declaration.is_search_input_valid_fn)(Value::Object(projected.clone())) {
			return Err(format!(
				"the search input does not satisfy the input of the view loader {:?}",
				owner.declaration.pattern
			));
		}
		search.extend(projected);
	}
	Ok(Some(search))
}

/// Which grammar a pattern uses: nested view patterns or flat endpoint
/// patterns.
#[derive(Clone, Copy, PartialEq, Eq)]
enum PatternKind {
	View,
	ApiEndpoint,
}

struct HrefParts<'a> {
	kind: PatternKind,
	pattern: &'a str,
	params: &'a [(String, String)],
	splat_values: &'a [String],
	search: Option<Map<String, Value>>,
	hash: Option<&'a str>,
}

/// The path of an href from a pattern, its params, and its splat values, by the rules of the
/// client's href builder (`packages/client/src/protocol/href.ts`). A `:name`
/// segment takes the param of that name and `*` takes the splat values. In a
/// view pattern, a trailing `_index` names its parent path and is omitted, and a
/// non-root splat needs a value. An API pattern keeps its trailing slash,
/// and an empty splat after other segments becomes a trailing slash.
fn build_path(parts: &HrefParts<'_>) -> Result<String, String> {
	let spelled = canonicalize_pattern(parts.pattern).map_err(|error| error.to_string())?;
	let has_trailing_slash =
		parts.kind == PatternKind::ApiEndpoint && spelled.len() > 1 && spelled.ends_with('/');
	let pattern_segments: Vec<&str> = if spelled == "/" {
		Vec::new()
	} else if has_trailing_slash {
		spelled[1..spelled.len() - 1].split('/').collect()
	} else {
		spelled[1..].split('/').collect()
	};
	let mut trailing_slash = has_trailing_slash;
	let mut segments = Vec::with_capacity(pattern_segments.len());
	let mut has_splat = false;
	for (index, segment) in pattern_segments.iter().enumerate() {
		let is_last = index + 1 == pattern_segments.len();
		if parts.kind == PatternKind::View && is_last && *segment == INDEX_SEGMENT {
			continue;
		}
		if segment.len() == SPLAT_SEGMENT.len_utf8() && segment.starts_with(SPLAT_SEGMENT) {
			has_splat = true;
			if parts.splat_values.is_empty() {
				match parts.kind {
					PatternKind::View if pattern_segments.len() > 1 => {
						return Err(format!(
							"the view pattern {:?} cannot select its non-root splat with no values",
							parts.pattern
						));
					}
					PatternKind::ApiEndpoint if !segments.is_empty() => trailing_slash = true,
					PatternKind::View | PatternKind::ApiEndpoint => {}
				}
			}
			for value in parts.splat_values {
				segments.push(encode_path_segment(value).map_err(|error| error.to_string())?);
			}
			continue;
		}
		if let Some(name) = segment.strip_prefix(DYNAMIC_PARAM_PREFIX) {
			let value = parts
				.params
				.iter()
				.find(|(param, _)| param == name)
				.map(|(_, value)| value)
				.ok_or_else(|| format!("the parameter {name} is missing"))?;
			segments.push(encode_path_segment(value).map_err(|error| error.to_string())?);
			continue;
		}
		segments.push((*segment).to_owned());
	}
	if !has_splat && !parts.splat_values.is_empty() {
		return Err(format!(
			"the pattern {:?} does not accept splat values",
			parts.pattern
		));
	}
	Ok(if segments.is_empty() {
		"/".to_owned()
	} else if trailing_slash {
		format!("/{}/", segments.join("/"))
	} else {
		format!("/{}", segments.join("/"))
	})
}

/// Appends the search and the hash of an href to its path.
fn append_search_and_hash(href: &mut String, parts: HrefParts<'_>) {
	if let Some(search) = parts.search.filter(|search| !search.is_empty()) {
		let entries = serialize_search_params(&Value::Object(search));
		let mut serializer = form_urlencoded::Serializer::new(String::new());
		serializer.extend_pairs(&entries);
		let query = serializer.finish();
		if !query.is_empty() {
			href.push('?');
			href.push_str(&query);
		}
	}
	if let Some(hash) = parts.hash.filter(|hash| !hash.is_empty()) {
		href.push('#');
		push_fragment(href, hash.strip_prefix('#').unwrap_or(hash));
	}
}

/// Percent-encodes a fragment with the URL standard's fragment percent-encode
/// set, as the `hash` setter of a URL does.
fn push_fragment(href: &mut String, fragment: &str) {
	for character in fragment.chars() {
		// The URL parser removes ASCII tabs and newlines before it encodes.
		if matches!(character, '\t' | '\n' | '\r') {
			continue;
		}
		let encode = matches!(character, '\u{0}'..='\u{1F}' | ' ' | '"' | '<' | '>' | '`')
			|| character > '\u{7E}';
		if !encode {
			href.push(character);
			continue;
		}
		let mut buffer = [0; 4];
		for byte in character.encode_utf8(&mut buffer).bytes() {
			href.push_str(&format!("%{byte:02X}"));
		}
	}
}