beet_router 0.0.8

ECS router and server utilities
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
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
use crate::prelude::*;
use beet_core::prelude::*;
use beet_flow::prelude::*;
use bevy::ecs::relationship::RelatedSpawner;

#[derive(Debug, Copy, Clone, PartialEq, Eq, Component, Reflect)]
#[reflect(Component)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "tokens", derive(ToTokens))]
pub enum ContentType {
	Html,
	Json,
}

/// Endpoints are actions that will only run if the method and path are an
/// exact match. There should only be one of these per route match,
/// unlike non-endpoint entities that behave as middleware.
///
/// Usually this is not added directly, instead via the [`Endpoint::build`] constructor.
/// Endpoints should only run if there are no trailing path segments,
/// unlike middleware which may run for multiple child paths. See [`check_exact_path`]
#[derive(Debug, Clone, Component, PartialEq, Eq, Reflect)]
#[reflect(Component)]
pub struct Endpoint {
	/// An optional description for this endpoint
	description: Option<String>,
	params: ParamsPattern,
	/// The full [`PathPattern`] for this endpoint
	path: PathPattern,
	/// The method to match, or None for any method.
	method: Option<HttpMethod>,
	/// The cache strategy for this endpoint, if any
	cache_strategy: Option<CacheStrategy>,
	/// Marks this endpoint as an HTML endpoint
	content_type: Option<ContentType>,
	/// Canonical endpoints are registered in the EndpointTree. Non-canonical endpoints
	/// are fallbacks that won't conflict with canonical routes. Defaults to `true`.
	is_canonical: bool,
}


impl Endpoint {
	#[cfg(test)]
	pub(crate) fn new(
		path: PathPattern,
		params: ParamsPattern,
		method: Option<HttpMethod>,
		cache_strategy: Option<CacheStrategy>,
		content_type: Option<ContentType>,
		is_canonical: bool,
	) -> Self {
		Self {
			path,
			params,
			method,
			cache_strategy,
			content_type,
			is_canonical,
			description: None,
		}
	}

	pub fn description(&self) -> Option<&str> { self.description.as_deref() }
	pub fn path(&self) -> &PathPattern { &self.path }
	pub fn params(&self) -> &ParamsPattern { &self.params }
	pub fn method(&self) -> Option<HttpMethod> { self.method }
	pub fn cache_strategy(&self) -> Option<CacheStrategy> {
		self.cache_strategy
	}
	pub fn content_type(&self) -> Option<ContentType> { self.content_type }
	pub fn is_canonical(&self) -> bool { self.is_canonical }

	/// Determines if this endpoint is a static GET endpoint
	pub fn is_static_get(&self) -> bool {
		self.path.is_static()
			&& self.method.map(|m| m == HttpMethod::Get).unwrap_or(true)
			&& self
				.cache_strategy
				.map(|s| s == CacheStrategy::Static)
				.unwrap_or(false)
	}
	/// Determines if this endpoint is a static GET endpoint returning HTML
	pub fn is_static_get_html(&self) -> bool {
		self.is_static_get() && self.content_type == Some(ContentType::Html)
	}
}

/// High level helper for building a correct [`Endpoint`] structure.
/// The flexibility of `beet_router` makes it challenging to build a correct
/// structure manually.
#[derive(BundleEffect)]
pub struct EndpointBuilder {
	/// The action to handle the request, by default always returns a 200 OK
	insert: Box<dyn 'static + Send + Sync + FnOnce(&mut EntityWorldMut)>,
	/// The path to match, or None for any path
	path: Option<PathPartial>,
	/// The params to match, or None for any params
	params: Option<ParamsPartial>,
	/// The method to match, or None for any method. Defaults to GET
	method: Option<HttpMethod>,
	/// The cache strategy for this endpoint, if any
	cache_strategy: Option<CacheStrategy>,
	/// Specify the content type for this endpoint
	content_type: Option<ContentType>,
	/// Whether to match the path exactly, defaults to true.
	exact_path: bool,
	/// Optional description for this endpoint
	description: Option<String>,
	/// Whether this endpoint is canonical (registered in EndpointTree), defaults to true
	is_canonical: bool,
	/// Additional bundles to be run before the handler
	additional_predicates: Vec<
		Box<
			dyn 'static
				+ Send
				+ Sync
				+ FnOnce(&mut RelatedSpawner<'_, ChildOf>),
		>,
	>,
}

impl Default for EndpointBuilder {
	fn default() -> Self {
		Self {
			insert: Box::new(|entity| {
				entity.insert(StatusCode::Ok.into_endpoint_handler());
			}),
			path: None,
			params: None,
			method: Some(HttpMethod::Get),
			cache_strategy: None,
			content_type: None,
			exact_path: true,
			description: None,
			is_canonical: true,
			additional_predicates: Vec::new(),
		}
	}
}

impl EndpointBuilder {
	pub fn new<M>(
		handler: impl 'static + Send + Sync + IntoEndpointHandler<M>,
	) -> Self {
		Self::default().with_handler(handler)
	}

	pub fn get() -> Self { Self::default().with_method(HttpMethod::Get) }
	pub fn post() -> Self { Self::default().with_method(HttpMethod::Post) }
	pub fn any_method() -> Self { Self::default().with_any_method() }

	/// Create middleware that accepts trailing path segments and any HTTP method.
	/// Middleware runs for all matching paths and does not consume the request.
	///
	/// Unlike traditional routers, beet middleware has deep understanding of the state
	/// of the exchange, for instance it can be used for templating content, where an
	/// endpoint inserts a [`HtmlBundle`](beet_rsx::prelude::HtmlBundle), and the middleware
	/// moves it into a layout.
	///
	/// It can also be used for traditional request/response middleware, see [common_middleware](./common_middleware.rs)
	///
	/// # Example
	/// ```
	/// # use beet_router::prelude::*;
	/// # use beet_core::prelude::*;
	/// # use beet_flow::prelude::*;
	/// # use beet_net::prelude::*;
	/// // Middleware that wraps HTML content in a layout
	/// EndpointBuilder::middleware(
	///     "blog",
	///     OnSpawn::observe(|ev: On<GetOutcome>, mut commands: Commands| {
	///         // Query for HtmlBundle on agent, wrap it, trigger Outcome::Pass
	///         commands.entity(ev.target()).trigger_target(Outcome::Pass);
	///     })
	/// );
	/// ```
	pub fn middleware(
		path: impl AsRef<str>,
		handler: impl 'static + Send + Sync + Bundle,
	) -> impl Bundle {
		(
			Name::new(format!("Middleware: {}", path.as_ref())),
			Sequence,
			PathPartial::new(path.as_ref()),
			children![partial_path_match(), handler],
		)
	}
	/// Create a new endpoint with the provided endpoint handler
	pub fn with_handler<M>(
		self,
		handler: impl 'static + Send + Sync + IntoEndpointHandler<M>,
	) -> Self {
		self.with_handler_bundle(handler.into_endpoint_handler())
	}
	/// Create a new endpoint with the provided bundle, the bundle must be
	/// a `GetOutcome` / `Outcome` action, and usually inserts a response
	/// or some type thats later converted to a response.
	pub fn with_handler_bundle(mut self, endpoint: impl Bundle) -> Self {
		self.insert = Box::new(move |entity| {
			entity.insert(endpoint);
		});
		self
	}

	pub fn with_path(mut self, path: impl AsRef<str>) -> Self {
		self.path = Some(PathPartial::new(path.as_ref()));
		self
	}

	pub fn with_params<T: bevy_reflect::Typed>(mut self) -> Self {
		self.params = Some(ParamsPartial::new::<T>());
		self
	}
	pub fn with_method(mut self, method: HttpMethod) -> Self {
		self.method = Some(method);
		self
	}
	pub fn with_any_method(mut self) -> Self {
		self.method = None;
		self
	}

	/// Add additional actions to be run before the handler,
	/// if they trigger a [`Outcome::Fail`] the handler will not run.
	pub fn with_predicate(
		mut self,
		predicate: impl Bundle + 'static + Send + Sync,
	) -> Self {
		self.additional_predicates.push(Box::new(move |spawner| {
			spawner.spawn(predicate);
		}));
		self
	}

	pub fn with_cache_strategy(mut self, strategy: CacheStrategy) -> Self {
		self.cache_strategy = Some(strategy);
		self
	}

	pub fn with_content_type(mut self, content_type: ContentType) -> Self {
		self.content_type = Some(content_type);
		self
	}

	/// Sets a description for this endpoint, used in help output
	pub fn with_description(mut self, description: impl Into<String>) -> Self {
		self.description = Some(description.into());
		self
	}

	/// Sets [`Self::exact_path`] to false
	pub fn with_trailing_path(mut self) -> Self {
		self.exact_path = false;
		self
	}

	/// Mark this endpoint as non-canonical, preventing it from being registered
	/// in the EndpointTree. Use this for fallback endpoints that shouldn't conflict
	/// with canonical routes.
	pub fn non_canonical(mut self) -> Self {
		self.is_canonical = false;
		self
	}

	fn effect(self, entity: &mut EntityWorldMut) {
		// the entity to eventually call [`Self::insert`] on, this will
		// be some nested entity depending on the builder configuration
		if let Some(pattern) = self.path {
			entity.insert(pattern);
		}
		if let Some(params) = self.params {
			entity.insert(params);
		}

		let id = entity.id();
		let path: PathPattern = entity.world_scope(|world| {
			world
				.run_system_cached_with(PathPattern::collect_system, id)
				.unwrap()
		});
		let params = entity
			.world_scope(|world| -> Result<ParamsPattern> {
				world
					.run_system_cached_with(ParamsPattern::collect_system, id)
					.unwrap()
			})
			.unwrap();

		entity
			.insert((
				Name::new(format!("Endpoint: {}", path.annotated_route_path())),
				Endpoint {
					path,
					params,
					description: self.description,
					method: self.method,
					cache_strategy: self.cache_strategy,
					content_type: self.content_type,
					is_canonical: self.is_canonical,
				},
				Sequence,
			))
			.with_children(|spawner| {
				// here we add the predicates as prior
				// children in the behavior tree.
				// Order is not important so long as the
				// handler is last.
				spawner.spawn(path_match(self.exact_path));

				if let Some(method) = self.method {
					spawner.spawn(check_method(method));
				}

				for predicate in self.additional_predicates {
					(predicate)(spawner);
				}

				let mut handler_entity =
					spawner.spawn(Name::new("Route Handler"));

				if let Some(cache_strategy) = self.cache_strategy {
					handler_entity.insert(cache_strategy);
				}
				if let Some(content_type) = self.content_type {
					handler_entity.insert(content_type);
				}
				if let Some(method) = self.method {
					handler_entity.insert(method);
				}
				(self.insert)(&mut handler_entity);
			});
	}
}

/// Will trigger [`Outcome::Pass`] if the request [`RoutePath`] satisfies the [`PathPattern`]
/// at this point in the tree with no remaining parts.
pub fn exact_path_match() -> impl Bundle { path_match(true) }
/// Will trigger [`Outcome::Pass`] if the request [`RoutePath`] satisfies the [`PathPattern`]
/// at this point in the tree, even if there are remaining parts.
pub fn partial_path_match() -> impl Bundle { path_match(false) }

fn path_match(must_exact_match: bool) -> impl Bundle {
	(
		Name::new("Check Path Match"),
		OnSpawn::observe(
			move |ev: On<GetOutcome>,
			      mut commands: Commands,
			      query: RouteQuery| {
				let action = ev.target();
				let outcome = match query.path_match(action) {
					// expected exact match, got partial match
					Ok(path_match)
						if must_exact_match && !path_match.exact_match() =>
					{
						Outcome::Fail
					}
					// got match
					Ok(_) => Outcome::Pass,
					// match failed
					Err(_err) => Outcome::Fail,
				};
				commands.entity(action).trigger_target(outcome);
			},
		),
	)
}


fn check_method(method: HttpMethod) -> impl Bundle {
	(
		Name::new("Method Check"),
		method,
		OnSpawn::observe(
			|ev: On<GetOutcome>,
			 query: RouteQuery,
			 actions: Query<&HttpMethod>,
			 mut commands: Commands|
			 -> Result {
				let action = ev.target();
				let method = actions.get(action)?;
				let outcome = match query.method(action)? == *method {
					true => Outcome::Pass,
					false => Outcome::Fail,
				};
				commands.entity(action).trigger_target(outcome);
				Ok(())
			},
		),
	)
}


#[cfg(test)]
mod test {
	use crate::prelude::*;
	use beet_core::prelude::*;
	use beet_flow::prelude::*;
	use beet_net::prelude::*;

	#[beet_core::test]
	async fn simple() {
		let _ = EndpointBuilder::new(|| {});
		let _ = EndpointBuilder::new(|| -> Result<(), String> { Ok(()) });

		RouterPlugin::world()
			.spawn(ExchangeSpawner::new_flow(|| EndpointBuilder::get()))
			.oneshot(Request::get("/"))
			.await
			.status()
			.xpect_eq(StatusCode::Ok);
	}

	#[beet_core::test]
	async fn dynamic_path() {
		RouterPlugin::world()
			.spawn(ExchangeSpawner::new_flow(|| {
				EndpointBuilder::get().with_path("/:path").with_handler(
					async |_req: (),
					       action: AsyncEntity|
					       -> Result<Html<String>> {
						let path =
							RouteQuery::dyn_segment_async(action, "path")
								.await?;
						Html(path).xok()
					},
				)
			}))
			.oneshot_str(Request::get("/bing"))
			.await
			.xpect_eq("bing");
	}

	#[beet_core::test]
	async fn children() {
		use beet_flow::prelude::*;

		let mut world = RouterPlugin::world();
		let mut entity = world.spawn(ExchangeSpawner::new_flow(|| {
			(InfallibleSequence, children![
				EndpointBuilder::get()
					.with_path("foo")
					.with_handler(|| "foo"),
				EndpointBuilder::get()
					.with_path("bar")
					.with_handler(|| "bar"),
			])
		}));
		entity.oneshot_str("/foo").await.xpect_eq("foo");
		entity.oneshot_str("/bar").await.xpect_eq("bar");
	}

	#[beet_core::test]
	async fn works() {
		let mut world = RouterPlugin::world();
		let mut entity = world.spawn(ExchangeSpawner::new_flow(|| {
			EndpointBuilder::post().with_path("foo")
		}));

		// method and path match
		entity
			.oneshot(Request::post("/foo"))
			.await
			.status()
			.xpect_eq(StatusCode::Ok);
		// method does not match - returns 500 because single endpoint failure
		// (404 requires a router with fallback structure)
		entity
			.oneshot(Request::get("/foo"))
			.await
			.status()
			.xpect_eq(StatusCode::InternalError);
		// path does not match
		entity
			.oneshot(Request::get("/bar"))
			.await
			.status()
			.xpect_eq(StatusCode::InternalError);
		// path has extra parts
		entity
			.oneshot(Request::get("/foo/bar"))
			.await
			.status()
			.xpect_eq(StatusCode::InternalError);
	}
	#[beet_core::test]
	async fn middleware_allows_trailing() {
		use beet_flow::prelude::*;

		let mut world = RouterPlugin::world();
		let mut entity = world.spawn(ExchangeSpawner::new_flow(|| {
			(InfallibleSequence, children![
				EndpointBuilder::middleware(
					"api",
					OnSpawn::observe(
						|ev: On<GetOutcome>, mut commands: Commands| {
							// Middleware just passes - demonstrates path matching
							commands
								.entity(ev.target())
								.trigger_target(Outcome::Pass);
						},
					),
				),
				EndpointBuilder::get()
					.with_path("api/users")
					.with_handler(|| "users"),
			])
		}));

		// Middleware allows trailing path segments, so this matches
		entity
			.oneshot(Request::get("/api/users"))
			.await
			.status()
			.xpect_eq(StatusCode::Ok);
	}


	#[test]
	fn test_collect_route_segments() {
		let mut world = World::new();
		world.spawn((
			PathPartial::new("foo"),
			EndpointBuilder::get(),
			children![
				children![
					(PathPartial::new("*bar"), EndpointBuilder::get()),
					PathPartial::new("bazz")
				],
				(PathPartial::new("qux"),),
				(PathPartial::new(":quax"), EndpointBuilder::get()),
			],
		));
		let mut paths = world
			.query_once::<&Endpoint>()
			.into_iter()
			.map(|endpoint| endpoint.path().annotated_route_path())
			.collect::<Vec<_>>();
		paths.sort();
		paths.xpect_eq(vec![
			RoutePath::new("/foo"),
			RoutePath::new("/foo/*bar"),
			RoutePath::new("/foo/:quax"),
		]);
	}

	#[beet_core::test]
	async fn response_exists() {
		// Simple test to verify Response exists after endpoint
		RouterPlugin::world()
			.spawn(ExchangeSpawner::new_flow(|| {
				(InfallibleSequence, children![
					EndpointBuilder::get()
						.with_handler(|| StatusCode::Ok.into_response()),
					OnSpawn::observe(
						|ev: On<GetOutcome>,
						 agents: AgentQuery,
						 response_query: Query<&Response>,
						 mut commands: Commands|
						 -> Result {
							let action = ev.target();
							let agent = agents.entity(action);
							response_query.contains(agent).xpect_true();
							commands
								.entity(action)
								.trigger_target(Outcome::Pass);
							Ok(())
						},
					),
				])
			}))
			.oneshot(Request::get("/"))
			.await
			.status()
			.xpect_eq(StatusCode::Ok);
	}
}