notify flows: GET returns a schema of the events, fields, node kinds and limits
This commit is contained in:
parent
627964f72d
commit
d5802d16a0
5 changed files with 281 additions and 26 deletions
|
|
@ -89,6 +89,22 @@ document, and gets 409 when the stored revision is another. Swarm UI's
|
||||||
[`/notifications`](ui.md#notification-flows) page shows the document and
|
[`/notifications`](ui.md#notification-flows) page shows the document and
|
||||||
switches built-ins.
|
switches built-ins.
|
||||||
|
|
||||||
|
**The schema.** `GET /api/notify/flows` also returns a `schema` object, built
|
||||||
|
from the constants the check reads, so an editor offers only what a `PUT`
|
||||||
|
accepts:
|
||||||
|
|
||||||
|
- `source_events`: `[{name, description}]`, the source events.
|
||||||
|
- `fields`: `[{name, description}]`, the condition and recipient fields, and
|
||||||
|
the `{field}` placeholders.
|
||||||
|
- `node_kinds`: `[{kind, description, fields}]` for `source`, `match` and
|
||||||
|
`todo`; each field is `{name, description, required, values}`, and `values`
|
||||||
|
lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a
|
||||||
|
todo's `op`), empty for other fields.
|
||||||
|
- `limits`: `{max_nodes, max_depth, max_flows}`.
|
||||||
|
|
||||||
|
A controller that predates `schema` leaves it out of the answer; a client reads
|
||||||
|
it as optional.
|
||||||
|
|
||||||
**What the router runs.** It reads the key at most every 10 seconds, when an
|
**What the router runs.** It reads the key at most every 10 seconds, when an
|
||||||
event arrives, and runs the built-ins you left on, then the custom flows that
|
event arrives, and runs the built-ins you left on, then the custom flows that
|
||||||
are on and pass the tree check. It logs a custom flow that fails the check,
|
are on and pass the tree check. It logs a custom flow that fails the check,
|
||||||
|
|
|
||||||
|
|
@ -2118,7 +2118,10 @@ mod tests {
|
||||||
let mut routed = events(DeliveryKind::Issues);
|
let mut routed = events(DeliveryKind::Issues);
|
||||||
routed.extend(events(DeliveryKind::PullRequest));
|
routed.extend(events(DeliveryKind::PullRequest));
|
||||||
routed.sort_unstable();
|
routed.sort_unstable();
|
||||||
let mut sources = swarm_wire::notify_flows::SOURCE_EVENTS.to_vec();
|
let mut sources = swarm_wire::notify_flows::SOURCE_EVENTS
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| *n)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
sources.sort_unstable();
|
sources.sort_unstable();
|
||||||
assert_eq!(routed, sources);
|
assert_eq!(routed, sources);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ pub(crate) struct FlowsView {
|
||||||
builtins: Vec<Flow>,
|
builtins: Vec<Flow>,
|
||||||
/// The custom flows.
|
/// The custom flows.
|
||||||
flows: Vec<Flow>,
|
flows: Vec<Flow>,
|
||||||
|
/// What this build accepts: source events, fields, node kinds and limits,
|
||||||
|
/// for an editor to offer. An older controller's answer has none.
|
||||||
|
schema: notify_flows::Schema,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body of a `PUT`: the whole document.
|
/// The body of a `PUT`: the whole document.
|
||||||
|
|
@ -67,6 +70,7 @@ impl FlowsView {
|
||||||
disabled_builtins: doc.disabled_builtins,
|
disabled_builtins: doc.disabled_builtins,
|
||||||
builtins: notify_flows::builtins(),
|
builtins: notify_flows::builtins(),
|
||||||
flows: doc.flows,
|
flows: doc.flows,
|
||||||
|
schema: notify_flows::schema(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -291,6 +295,10 @@ mod tests {
|
||||||
assert_eq!(view.revision, 0);
|
assert_eq!(view.revision, 0);
|
||||||
assert!(view.disabled_builtins.is_empty() && view.flows.is_empty());
|
assert!(view.disabled_builtins.is_empty() && view.flows.is_empty());
|
||||||
assert_eq!(view.builtins.len(), 3);
|
assert_eq!(view.builtins.len(), 3);
|
||||||
|
let json = serde_json::to_value(&view).expect("serializes");
|
||||||
|
assert_eq!(json["schema"]["limits"]["max_nodes"], 64);
|
||||||
|
assert_eq!(json["schema"]["node_kinds"][0]["kind"], "source");
|
||||||
|
assert_eq!(view.schema, notify_flows::schema());
|
||||||
|
|
||||||
let Json(saved) = put_flows(
|
let Json(saved) = put_flows(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,10 @@ mod tests {
|
||||||
.keys()
|
.keys()
|
||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
let mut want = swarm_wire::notify_flows::FIELDS.to_vec();
|
let mut want = swarm_wire::notify_flows::FIELDS
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| *n)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
got.sort_unstable();
|
got.sort_unstable();
|
||||||
want.sort_unstable();
|
want.sort_unstable();
|
||||||
assert_eq!(got, want, "{event}");
|
assert_eq!(got, want, "{event}");
|
||||||
|
|
|
||||||
|
|
@ -40,29 +40,57 @@ pub const MAX_FLOWS: usize = 100;
|
||||||
/// The longest a flow's name, or a todo's key or text, may be.
|
/// The longest a flow's name, or a todo's key or text, may be.
|
||||||
pub const MAX_TEXT: usize = 500;
|
pub const MAX_TEXT: usize = 500;
|
||||||
|
|
||||||
/// The forge event types a source node can start from: the ones
|
/// The forge event types a source node can start from, with what each means:
|
||||||
/// swarm-controller's hooks deliver to the events stream.
|
/// the ones swarm-controller's hooks deliver to the events stream.
|
||||||
pub const SOURCE_EVENTS: &[&str] = &[
|
pub const SOURCE_EVENTS: &[(&str, &str)] = &[
|
||||||
"issues",
|
(
|
||||||
"issue_assign",
|
"issues",
|
||||||
"pull_request",
|
"An issue opened, closed, edited or otherwise changed",
|
||||||
"pull_request_assign",
|
),
|
||||||
|
("issue_assign", "An issue assigned or unassigned"),
|
||||||
|
(
|
||||||
|
"pull_request",
|
||||||
|
"A pull request opened, closed, edited or otherwise changed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pull_request_assign",
|
||||||
|
"A pull request assigned or unassigned",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The fields of an event a condition or a recipient can name.
|
/// The fields of an event a condition or a recipient can name, with what each
|
||||||
pub const FIELDS: &[&str] = &[
|
/// holds. A todo's key and text read them as `{field}` placeholders.
|
||||||
"event",
|
pub const FIELDS: &[(&str, &str)] = &[
|
||||||
"action",
|
("event", "The event type, one of the source events"),
|
||||||
"kind",
|
("action", "What happened, such as opened, closed, assigned"),
|
||||||
"repo",
|
("kind", "issue or pull request"),
|
||||||
"number",
|
("repo", "The repository, owner/name"),
|
||||||
"title",
|
("number", "The issue or pull request number"),
|
||||||
"url",
|
("title", "The issue or pull request title"),
|
||||||
"sender",
|
("url", "The web link to the issue or pull request"),
|
||||||
"assignees",
|
("sender", "The account that caused the event"),
|
||||||
"assignees_added",
|
("assignees", "The agents assigned after the event (a list)"),
|
||||||
"assignees_removed",
|
("assignees_added", "The agents the event assigned (a list)"),
|
||||||
"todo_holders",
|
(
|
||||||
|
"assignees_removed",
|
||||||
|
"The agents the event unassigned (a list)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"todo_holders",
|
||||||
|
"The agents holding a router todo for the issue or pull request (a list)",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The todo ops a `todo` node's `op` takes, one per [`Op`] variant.
|
||||||
|
const OPS: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"upsert",
|
||||||
|
"Add the todo, or update the one under the same key",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clear",
|
||||||
|
"Remove the todo under the key, or with prefix every one starting with it",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The value under [`KEY`].
|
/// The value under [`KEY`].
|
||||||
|
|
@ -288,7 +316,134 @@ fn is_id(id: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_field(name: &str) -> bool {
|
fn is_field(name: &str) -> bool {
|
||||||
FIELDS.contains(&name)
|
FIELDS.iter().any(|(n, _)| *n == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_source_event(name: &str) -> bool {
|
||||||
|
SOURCE_EVENTS.iter().any(|(n, _)| *n == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name with a one-line description.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
|
pub struct Described {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One field of a node kind.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
|
pub struct KindField {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
/// Whether a node of the kind must set it.
|
||||||
|
pub required: bool,
|
||||||
|
/// The accepted values when the field takes one of a fixed few; empty
|
||||||
|
/// otherwise.
|
||||||
|
pub values: Vec<Described>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One kind of node and the fields it takes besides `id` and `kind`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
|
pub struct KindSchema {
|
||||||
|
pub kind: String,
|
||||||
|
pub description: String,
|
||||||
|
pub fields: Vec<KindField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The limits [`Document::check`] enforces.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
|
pub struct Limits {
|
||||||
|
pub max_nodes: usize,
|
||||||
|
pub max_depth: usize,
|
||||||
|
pub max_flows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a flow editor needs to offer only what [`Document::check`] accepts,
|
||||||
|
/// built from the constants the check reads.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
|
pub struct Schema {
|
||||||
|
pub source_events: Vec<Described>,
|
||||||
|
pub fields: Vec<Described>,
|
||||||
|
pub node_kinds: Vec<KindSchema>,
|
||||||
|
pub limits: Limits,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn described(list: &[(&str, &str)]) -> Vec<Described> {
|
||||||
|
list.iter()
|
||||||
|
.map(|(name, description)| Described {
|
||||||
|
name: (*name).to_owned(),
|
||||||
|
description: (*description).to_owned(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_field(name: &str, description: &str, required: bool) -> KindField {
|
||||||
|
KindField {
|
||||||
|
name: name.to_owned(),
|
||||||
|
description: description.to_owned(),
|
||||||
|
required,
|
||||||
|
values: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The schema of this build's flows.
|
||||||
|
#[must_use]
|
||||||
|
pub fn schema() -> Schema {
|
||||||
|
let mut op = kind_field("op", "What the todo does to the recipient's list", true);
|
||||||
|
op.values = described(OPS);
|
||||||
|
Schema {
|
||||||
|
source_events: described(SOURCE_EVENTS),
|
||||||
|
fields: described(FIELDS),
|
||||||
|
node_kinds: vec![
|
||||||
|
KindSchema {
|
||||||
|
kind: "source".to_owned(),
|
||||||
|
description: "The root: the forge events the flow starts from".to_owned(),
|
||||||
|
fields: vec![kind_field(
|
||||||
|
"events",
|
||||||
|
"One or more of the source events",
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
KindSchema {
|
||||||
|
kind: "match".to_owned(),
|
||||||
|
description: "Passes when every condition holds; each child then runs".to_owned(),
|
||||||
|
fields: vec![kind_field(
|
||||||
|
"when",
|
||||||
|
"Conditions, each {field, equals}; a list field holds when one item equals",
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
KindSchema {
|
||||||
|
kind: "todo".to_owned(),
|
||||||
|
description: "A leaf: emits one todo op per recipient".to_owned(),
|
||||||
|
fields: vec![
|
||||||
|
kind_field(
|
||||||
|
"to",
|
||||||
|
"The recipients: {field: <event field>} or {agent: <agent name>}",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
op,
|
||||||
|
kind_field("key", "The todo key, with {field} placeholders", true),
|
||||||
|
kind_field("prefix", "For a clear: key is a prefix", false),
|
||||||
|
kind_field(
|
||||||
|
"text",
|
||||||
|
"The todo text, with {field} placeholders; required for an upsert, empty for a clear",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limits: Limits {
|
||||||
|
max_nodes: MAX_NODES,
|
||||||
|
max_depth: MAX_DEPTH,
|
||||||
|
max_flows: MAX_FLOWS,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `{name}` placeholders in `template`, in order. A `{` with no `}` after
|
/// The `{name}` placeholders in `template`, in order. A `{` with no `}` after
|
||||||
|
|
@ -412,10 +567,14 @@ impl NodeKind {
|
||||||
if events.is_empty() {
|
if events.is_empty() {
|
||||||
return Err("a source with no events".to_owned());
|
return Err("a source with no events".to_owned());
|
||||||
}
|
}
|
||||||
if let Some(bad) = events.iter().find(|e| !SOURCE_EVENTS.contains(&e.as_str())) {
|
if let Some(bad) = events.iter().find(|e| !is_source_event(e)) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"event {bad:?} is not one of {}",
|
"event {bad:?} is not one of {}",
|
||||||
SOURCE_EVENTS.join(", ")
|
SOURCE_EVENTS
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| *n)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -590,6 +749,72 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the schema offers is what the check accepts, and nothing else is.
|
||||||
|
#[test]
|
||||||
|
fn the_schema_lists_what_a_flow_may_name() {
|
||||||
|
let s = schema();
|
||||||
|
let with_event = |name: &str| {
|
||||||
|
let mut f = builtin("assigned");
|
||||||
|
f.nodes[0].kind = NodeKind::Source {
|
||||||
|
events: vec![name.to_owned()],
|
||||||
|
};
|
||||||
|
f.validate()
|
||||||
|
};
|
||||||
|
assert!(!s.source_events.is_empty());
|
||||||
|
for e in &s.source_events {
|
||||||
|
assert_eq!(with_event(&e.name), Ok(()), "{}", e.name);
|
||||||
|
assert!(!e.description.is_empty(), "{}", e.name);
|
||||||
|
}
|
||||||
|
assert!(with_event("no_such_event").is_err(), "control");
|
||||||
|
|
||||||
|
let with_field = |name: &str| {
|
||||||
|
let mut f = builtin("assigned");
|
||||||
|
f.nodes[1].kind = NodeKind::Match {
|
||||||
|
when: vec![Condition {
|
||||||
|
field: name.to_owned(),
|
||||||
|
equals: "x".to_owned(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
f.validate()
|
||||||
|
};
|
||||||
|
assert!(!s.fields.is_empty());
|
||||||
|
for field in &s.fields {
|
||||||
|
assert_eq!(with_field(&field.name), Ok(()), "{}", field.name);
|
||||||
|
assert!(!field.description.is_empty(), "{}", field.name);
|
||||||
|
}
|
||||||
|
assert!(with_field("no_such_field").is_err(), "control");
|
||||||
|
|
||||||
|
let kinds: Vec<&str> = s.node_kinds.iter().map(|k| k.kind.as_str()).collect();
|
||||||
|
assert_eq!(kinds, ["source", "match", "todo"]);
|
||||||
|
let op_names = |op: &str| -> Option<Op> {
|
||||||
|
serde_json::from_value(serde_json::Value::String(op.to_owned())).ok()
|
||||||
|
};
|
||||||
|
let todo = s
|
||||||
|
.node_kinds
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.kind == "todo")
|
||||||
|
.expect("todo");
|
||||||
|
let op = todo.fields.iter().find(|f| f.name == "op").expect("op");
|
||||||
|
let listed: Vec<Op> = op
|
||||||
|
.values
|
||||||
|
.iter()
|
||||||
|
.map(|v| op_names(&v.name).expect("an op"))
|
||||||
|
.collect();
|
||||||
|
// Every `Op` variant is listed: this match stops compiling when one is added.
|
||||||
|
for o in [Op::Upsert, Op::Clear] {
|
||||||
|
match o {
|
||||||
|
Op::Upsert | Op::Clear => assert!(listed.contains(&o), "{o:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(listed.len(), 2);
|
||||||
|
assert!(op_names("nope").is_none(), "control");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows),
|
||||||
|
(MAX_NODES, MAX_DEPTH, MAX_FLOWS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_flow_that_is_not_a_tree_is_refused() {
|
fn a_flow_that_is_not_a_tree_is_refused() {
|
||||||
let mut cycle = builtin("assigned");
|
let mut cycle = builtin("assigned");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue