1use std::collections::{BTreeSet, HashSet};
15
16use http::Method;
17use schemars::{JsonSchema, Schema, SchemaGenerator};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::config::ConfigError;
22use crate::module::{HARNESS_API, Module};
23use crate::venture::Venture;
24
25pub const SURFACE_API: u32 = 1;
29
30pub const HINT_KEYWORDS: &[&str] = &[
43 "x-cf-label",
44 "x-cf-placeholder",
45 "x-cf-help",
46 "x-cf-widget",
47 "x-cf-hidden",
48 "x-cf-options",
49];
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Audience {
56 Public,
59 Admin,
62 Link,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "kebab-case")]
71pub enum Outcome {
72 Accepted { message: String },
75 Redirect,
77 Json,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Action {
84 pub name: String,
87 #[serde(with = "method_serde")]
89 pub method: Method,
90 pub path: String,
92 pub audience: Audience,
93 #[serde(skip_serializing_if = "Option::is_none")]
96 pub input: Option<Schema>,
97 pub outcome: Outcome,
98 pub captcha: bool,
101}
102
103impl Action {
104 #[must_use]
107 pub fn post(name: impl Into<String>, path: impl Into<String>) -> Self {
108 Self::new(name, Method::POST, path)
109 }
110
111 #[must_use]
114 pub fn get(name: impl Into<String>, path: impl Into<String>) -> Self {
115 Self::new(name, Method::GET, path)
116 .audience(Audience::Link)
117 .outcome(Outcome::Redirect)
118 }
119
120 #[must_use]
122 pub fn delete(name: impl Into<String>, path: impl Into<String>) -> Self {
123 Self::new(name, Method::DELETE, path)
124 .audience(Audience::Admin)
125 .outcome(Outcome::Json)
126 }
127
128 #[must_use]
129 pub fn new(name: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
130 Self {
131 name: name.into(),
132 method,
133 path: path.into(),
134 audience: Audience::Public,
135 input: None,
136 outcome: Outcome::Accepted {
137 message: "Thanks, you're in.".to_owned(),
138 },
139 captcha: false,
140 }
141 }
142
143 #[must_use]
144 pub fn audience(mut self, audience: Audience) -> Self {
145 self.audience = audience;
146 self
147 }
148
149 #[must_use]
151 pub fn input<T: JsonSchema>(mut self) -> Self {
152 self.input = Some(schema_for::<T>());
153 self
154 }
155
156 #[must_use]
159 pub fn input_schema(mut self, schema: Schema) -> Self {
160 self.input = Some(schema);
161 self
162 }
163
164 #[must_use]
165 pub fn outcome(mut self, outcome: Outcome) -> Self {
166 self.outcome = outcome;
167 self
168 }
169
170 #[must_use]
172 pub fn accepted(self, message: impl Into<String>) -> Self {
173 self.outcome(Outcome::Accepted {
174 message: message.into(),
175 })
176 }
177
178 #[must_use]
179 pub fn captcha(mut self) -> Self {
180 self.captcha = true;
181 self
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Column {
188 pub key: String,
190 pub label: String,
191}
192
193impl Column {
194 #[must_use]
195 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
196 Self {
197 key: key.into(),
198 label: label.into(),
199 }
200 }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "kind", rename_all = "kebab-case")]
206pub enum View {
207 Form { action: String },
209 Status { action: String },
211 Table {
213 source: String,
214 columns: Vec<Column>,
215 },
216}
217
218impl View {
219 #[must_use]
220 pub fn form(action: impl Into<String>) -> Self {
221 View::Form {
222 action: action.into(),
223 }
224 }
225
226 #[must_use]
227 pub fn status(action: impl Into<String>) -> Self {
228 View::Status {
229 action: action.into(),
230 }
231 }
232
233 #[must_use]
234 pub fn table(source: impl Into<String>, columns: Vec<Column>) -> Self {
235 View::Table {
236 source: source.into(),
237 columns,
238 }
239 }
240
241 fn action_names(&self) -> Vec<&str> {
242 match self {
243 View::Form { action } | View::Status { action } => vec![action],
244 View::Table { source, .. } => vec![source],
245 }
246 }
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
251pub struct Surface {
252 pub actions: Vec<Action>,
253 pub views: Vec<View>,
254}
255
256impl Surface {
257 #[must_use]
259 pub fn none() -> Self {
260 Self::default()
261 }
262
263 #[must_use]
264 pub fn new() -> Self {
265 Self::default()
266 }
267
268 #[must_use]
269 pub fn action(mut self, action: Action) -> Self {
270 self.actions.push(action);
271 self
272 }
273
274 #[must_use]
275 pub fn view(mut self, view: View) -> Self {
276 self.views.push(view);
277 self
278 }
279
280 #[must_use]
282 pub fn is_empty(&self) -> bool {
283 self.actions.is_empty() && self.views.is_empty()
284 }
285
286 pub fn validate(&self, module: &str, errors: &mut ConfigError) {
292 let mut seen: HashSet<&str> = HashSet::new();
293 for action in &self.actions {
294 let name = action.name.as_str();
295 if !is_kebab(name) {
296 errors.push(format!(
297 "module `{module}` surface action `{name}` must be kebab-case"
298 ));
299 }
300 if !seen.insert(name) {
301 errors.push(format!(
302 "module `{module}` surface declares action `{name}` twice"
303 ));
304 }
305 if !action.path.starts_with('/') {
306 errors.push(format!(
307 "module `{module}` surface action `{name}` path `{}` must start with '/' \
308 (relative to /v1/{module})",
309 action.path
310 ));
311 }
312 let under_admin = action.path == "/admin" || action.path.starts_with("/admin/");
313 match action.audience {
314 Audience::Admin if !under_admin => errors.push(format!(
315 "module `{module}` surface action `{name}` is admin but its path `{}` \
316 is not under /admin/",
317 action.path
318 )),
319 Audience::Public | Audience::Link if under_admin => errors.push(format!(
320 "module `{module}` surface action `{name}` is under /admin/ but its \
321 audience is not admin",
322 )),
323 _ => {}
324 }
325 if let Some(schema) = &action.input
326 && !is_object_schema(schema)
327 {
328 errors.push(format!(
329 "module `{module}` surface action `{name}` input schema must describe an \
330 object (a struct with named fields), so a renderer can lay out fields"
331 ));
332 }
333 }
334 for view in &self.views {
335 for referenced in view.action_names() {
336 if !seen.contains(referenced) {
337 errors.push(format!(
338 "module `{module}` surface view references action `{referenced}` \
339 which the module does not declare"
340 ));
341 }
342 }
343 }
344 }
345
346 #[must_use]
349 pub fn public(&self) -> Surface {
350 let actions: Vec<Action> = self
351 .actions
352 .iter()
353 .filter(|action| action.audience != Audience::Admin)
354 .cloned()
355 .collect();
356 let names: BTreeSet<&str> = actions.iter().map(|a| a.name.as_str()).collect();
357 let views = self
358 .views
359 .iter()
360 .filter(|view| view.action_names().iter().all(|n| names.contains(n)))
361 .cloned()
362 .collect();
363 Surface { actions, views }
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct ModuleSurface {
370 pub name: String,
371 pub version: String,
372 #[serde(flatten)]
373 pub surface: Surface,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct VentureSurface {
379 pub name: String,
380 pub public_url: String,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct SurfaceDocument {
387 pub surface_api: u32,
388 pub harness_api: u32,
389 pub venture: VentureSurface,
390 pub modules: Vec<ModuleSurface>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub ui: Option<serde_json::Value>,
394}
395
396impl SurfaceDocument {
397 #[must_use]
399 pub fn compose(venture: &Venture, modules: &[std::sync::Arc<dyn Module>]) -> Self {
400 let modules = modules
401 .iter()
402 .map(|module| ModuleSurface {
403 name: module.name().to_owned(),
404 version: module.version().to_owned(),
405 surface: module.surface(),
406 })
407 .filter(|entry| !entry.surface.is_empty())
408 .collect();
409 Self {
410 surface_api: SURFACE_API,
411 harness_api: HARNESS_API,
412 venture: VentureSurface {
413 name: venture.name.clone(),
414 public_url: venture.public_url.clone(),
415 },
416 modules,
417 ui: None,
418 }
419 }
420
421 #[must_use]
424 pub fn public(&self) -> Self {
425 Self {
426 surface_api: self.surface_api,
427 harness_api: self.harness_api,
428 venture: self.venture.clone(),
429 modules: self
430 .modules
431 .iter()
432 .map(|entry| ModuleSurface {
433 name: entry.name.clone(),
434 version: entry.version.clone(),
435 surface: entry.surface.public(),
436 })
437 .filter(|entry| !entry.surface.is_empty())
438 .collect(),
439 ui: self.ui.clone(),
440 }
441 }
442}
443
444#[async_trait::async_trait]
450pub trait SurfaceSource: Send + Sync {
451 async fn current(&self) -> std::sync::Arc<SurfaceDocument>;
454 fn built(&self) -> std::sync::Arc<SurfaceDocument>;
457 fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
460 let _ = admin;
461 None
462 }
463}
464
465pub struct UiContext {
468 pub surface: std::sync::Arc<dyn SurfaceSource>,
472 pub api: axum::Router,
478 pub config: std::sync::Arc<dyn crate::config::Config>,
479 pub venture: std::sync::Arc<Venture>,
480 pub captcha_configured: bool,
483 pub signer: Option<std::sync::Arc<dyn crate::ports::Signer>>,
486 pub rate_limiter: Option<std::sync::Arc<dyn crate::ports::RateLimiter>>,
488}
489
490pub trait UiMount: Send + Sync + 'static {
494 fn router(&self, ctx: UiContext) -> axum::Router;
496 fn validate(&self, surface: &SurfaceDocument, errors: &mut ConfigError) {
500 let _ = (surface, errors);
501 }
502 fn describe(&self) -> Option<serde_json::Value> {
506 None
507 }
508}
509
510#[derive(Debug, Clone)]
514pub struct RenderedSurface {
515 pub json: String,
516 pub etag: String,
518}
519
520impl RenderedSurface {
521 #[must_use]
522 pub fn render(document: &SurfaceDocument) -> Self {
523 let json = serde_json::to_string(document).unwrap_or_else(|_| "{}".to_owned());
524 let digest = Sha256::digest(json.as_bytes());
525 let mut hex = String::with_capacity(32);
526 for byte in &digest[..16] {
527 use std::fmt::Write as _;
528 let _ = write!(hex, "{byte:02x}");
529 }
530 Self {
531 json,
532 etag: format!("\"{hex}\""),
533 }
534 }
535}
536
537#[must_use]
540pub fn schema_for<T: JsonSchema>() -> Schema {
541 let mut settings = schemars::generate::SchemaSettings::draft2020_12();
542 settings.inline_subschemas = true;
543 SchemaGenerator::new(settings).into_root_schema_for::<T>()
544}
545
546pub fn hint_field(schema: &mut Schema, field: &str, key: &str, value: serde_json::Value) {
551 if let Some(properties) = schema
552 .as_object_mut()
553 .and_then(|root| root.get_mut("properties"))
554 .and_then(serde_json::Value::as_object_mut)
555 && let Some(property) = properties
556 .get_mut(field)
557 .and_then(serde_json::Value::as_object_mut)
558 {
559 property.insert(key.to_owned(), value);
560 }
561}
562
563fn is_object_schema(schema: &Schema) -> bool {
564 let value = schema.as_value();
565 match value.get("type") {
566 Some(serde_json::Value::String(t)) => t == "object",
567 Some(serde_json::Value::Array(types)) => types.iter().any(|t| t == "object"),
568 _ => value.get("properties").is_some(),
569 }
570}
571
572fn is_kebab(name: &str) -> bool {
573 !name.is_empty()
574 && name.split('-').all(|part| {
575 !part.is_empty()
576 && part
577 .chars()
578 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
579 })
580}
581
582mod method_serde {
583 use http::Method;
584 use serde::{Deserialize, Deserializer, Serialize, Serializer};
585
586 pub fn serialize<S: Serializer>(method: &Method, serializer: S) -> Result<S::Ok, S::Error> {
587 method.as_str().serialize(serializer)
588 }
589
590 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Method, D::Error> {
591 let text = String::deserialize(deserializer)?;
592 Method::from_bytes(text.as_bytes()).map_err(serde::de::Error::custom)
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[derive(JsonSchema)]
601 #[allow(dead_code)]
602 struct JoinBody {
603 #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
604 email: String,
605 product: String,
606 #[schemars(extend("x-cf-hidden" = true))]
607 #[serde(rename = "captchaToken")]
608 captcha_token: Option<String>,
609 }
610
611 fn join() -> Action {
612 Action::post("join", "/").input::<JoinBody>().captcha()
613 }
614
615 fn errors_of(surface: &Surface) -> Vec<String> {
616 let mut errors = ConfigError::default();
617 surface.validate("waitlist", &mut errors);
618 match errors.into_result() {
619 Ok(()) => Vec::new(),
620 Err(err) => err.to_string().lines().skip(1).map(str::to_owned).collect(),
621 }
622 }
623
624 #[test]
625 fn schema_carries_hints_and_is_inlined() {
626 let schema = schema_for::<JoinBody>();
627 let value = schema.as_value();
628 assert_eq!(value["type"], "object");
629 assert_eq!(value["properties"]["email"]["x-cf-label"], "Email");
630 assert_eq!(value["properties"]["email"]["x-cf-widget"], "email");
631 assert_eq!(value["properties"]["captchaToken"]["x-cf-hidden"], true);
632 assert!(value.get("$defs").is_none(), "subschemas must be inlined");
633 }
634
635 #[test]
636 fn hint_field_sets_a_keyword_and_ignores_unknown_fields() {
637 let mut schema = schema_for::<JoinBody>();
638 hint_field(
639 &mut schema,
640 "product",
641 "enum",
642 serde_json::json!(["a", "b"]),
643 );
644 hint_field(&mut schema, "missing", "x-cf-label", serde_json::json!("x"));
645 let value = schema.as_value();
646 assert_eq!(
647 value["properties"]["product"]["enum"],
648 serde_json::json!(["a", "b"])
649 );
650 assert!(value["properties"].get("missing").is_none());
651 }
652
653 #[test]
654 fn valid_surface_has_no_errors() {
655 let surface = Surface::new()
656 .action(join())
657 .action(Action::get("confirm", "/confirm"))
658 .action(
659 Action::get("export", "/admin/export.csv")
660 .audience(Audience::Admin)
661 .outcome(Outcome::Json),
662 )
663 .view(View::form("join"))
664 .view(View::table("export", vec![Column::new("email", "Email")]));
665 assert!(errors_of(&surface).is_empty());
666 }
667
668 #[test]
669 fn every_validation_rule_names_the_module_and_action() {
670 #[derive(JsonSchema)]
671 #[allow(dead_code)]
672 struct NotAnObject(Vec<String>);
673
674 let surface = Surface::new()
675 .action(join())
676 .action(join())
677 .action(Action::post("Bad Name", "no-slash"))
678 .action(Action::post("hidden", "/admin/thing"))
679 .action(Action::delete("wipe", "/wipe"))
680 .action(Action::post("list", "/list").input::<NotAnObject>())
681 .view(View::form("missing"));
682 let errors = errors_of(&surface);
683 let joined = errors.join("\n");
684 for needle in [
685 "declares action `join` twice",
686 "action `Bad Name` must be kebab-case",
687 "path `no-slash` must start with '/'",
688 "action `hidden` is under /admin/ but its audience is not admin",
689 "action `wipe` is admin but its path `/wipe` is not under /admin/",
690 "action `list` input schema must describe an object",
691 "view references action `missing`",
692 ] {
693 assert!(joined.contains(needle), "missing `{needle}` in:\n{joined}");
694 }
695 assert!(joined.lines().all(|l| l.contains("`waitlist`")), "{joined}");
696 }
697
698 #[test]
699 fn public_subset_drops_admin_actions_and_their_views() {
700 let surface = Surface::new()
701 .action(join())
702 .action(
703 Action::get("export", "/admin/export.csv")
704 .audience(Audience::Admin)
705 .outcome(Outcome::Json),
706 )
707 .view(View::form("join"))
708 .view(View::table("export", vec![]));
709 let public = surface.public();
710 assert_eq!(public.actions.len(), 1);
711 assert_eq!(public.views.len(), 1);
712 assert!(matches!(public.views[0], View::Form { .. }));
713 }
714
715 #[test]
716 fn rendered_surface_etag_is_stable_and_differs_per_variant() {
717 let doc = SurfaceDocument {
718 surface_api: SURFACE_API,
719 harness_api: HARNESS_API,
720 venture: VentureSurface {
721 name: "v".into(),
722 public_url: "https://v.test".into(),
723 },
724 modules: vec![ModuleSurface {
725 name: "waitlist".into(),
726 version: "0.1.0".into(),
727 surface: Surface::new()
728 .action(join())
729 .action(Action::delete("wipe", "/admin/wipe")),
730 }],
731 ui: None,
732 };
733 let full = RenderedSurface::render(&doc);
734 let again = RenderedSurface::render(&doc);
735 let public = RenderedSurface::render(&doc.public());
736 assert_eq!(full.etag, again.etag);
737 assert_ne!(full.etag, public.etag);
738 assert!(full.etag.starts_with('"') && full.etag.ends_with('"'));
739 assert_eq!(full.etag.len(), 34);
740 let parsed: serde_json::Value = serde_json::from_str(&full.json).unwrap();
741 assert_eq!(parsed["modules"][0]["actions"][0]["method"], "POST");
742 assert_eq!(parsed["modules"][0]["actions"][1]["audience"], "admin");
743 assert_eq!(parsed["surface_api"], SURFACE_API);
744 }
745
746 #[test]
747 fn document_omits_modules_without_a_surface() {
748 struct Silent;
749 impl Module for Silent {
750 fn name(&self) -> &'static str {
751 "silent"
752 }
753 fn version(&self) -> &'static str {
754 "0.0.0"
755 }
756 fn requires(&self) -> &'static [crate::ports::Port] {
757 &[]
758 }
759 fn migrations(&self) -> crate::module::Migrations {
760 crate::module::Migrations::EMPTY
761 }
762 fn validate_config(&self, _: &dyn crate::config::Config) -> Result<(), ConfigError> {
763 Ok(())
764 }
765 fn router(&self, _: crate::module::ModuleContext) -> axum::Router {
766 axum::Router::new()
767 }
768 }
769 let venture = Venture::new("v", "v.test");
770 let modules: Vec<std::sync::Arc<dyn Module>> = vec![std::sync::Arc::new(Silent)];
771 let doc = SurfaceDocument::compose(&venture, &modules);
772 assert!(doc.modules.is_empty());
773 }
774}