1use noxid_ir::{
2 ComponentDefinition, EndpointCachePolicy, EndpointHandler, EndpointInputSection, EndpointKind,
3 EndpointLimitPolicy, EndpointMethod, ExecutionTarget, FileUploadContract, QueueHandler,
4 SemanticExpr, SemanticId, SemanticProgram, SemanticStatement, TaskHandler,
5};
6use noxid_source::{Span, json_escape};
7
8pub const SCHEMA_VERSION: u32 = 18;
9
10#[derive(Clone, Debug, Default)]
11pub struct ExecutionProgram {
12 pub live_resources: Vec<LiveResourceExecutionContract>,
13 pub presences: Vec<PresenceExecutionContract>,
14 pub boundaries: Vec<ExecutionBoundary>,
15 pub endpoints: Vec<EndpointExecutionBoundary>,
16 pub tasks: Vec<TaskExecutionBoundary>,
17 pub queues: Vec<QueueExecutionBoundary>,
18}
19
20#[derive(Clone, Debug)]
21pub struct PresenceExecutionContract {
22 pub id: SemanticId,
23 pub component: SemanticId,
24 pub component_name: String,
25 pub stream: SemanticId,
26 pub record_type: SemanticId,
27 pub member_type: SemanticId,
28 pub snapshot_type: SemanticId,
29 pub fields: Vec<PresenceExecutionField>,
30 pub capabilities: Vec<String>,
31 pub route_scopes: Vec<ExecutionRouteScope>,
32 pub ttl_ms: u64,
33 pub heartbeat_ms: u64,
34}
35
36#[derive(Clone, Debug)]
37pub struct PresenceExecutionField {
38 pub id: SemanticId,
39 pub name: String,
40 pub ty: String,
41 pub type_id: Option<SemanticId>,
42}
43
44#[derive(Clone, Debug)]
45pub struct LiveResourceExecutionContract {
46 pub id: SemanticId,
47 pub name: String,
48 pub capabilities: Vec<String>,
49 pub route_scopes: Vec<ExecutionRouteScope>,
50}
51
52#[derive(Clone, Debug)]
53pub struct QueueExecutionBoundary {
54 pub id: SemanticId,
55 pub host_key: Option<SemanticId>,
56 pub name: String,
57 pub payload: Vec<QueueExecutionField>,
58 pub retry: u32,
59 pub backoff_ms: u64,
60 pub statements: Vec<SemanticStatement>,
61 pub invalidates: Vec<SemanticId>,
62 pub span: Span,
63}
64
65#[derive(Clone, Debug)]
66pub struct QueueExecutionField {
67 pub id: SemanticId,
68 pub name: String,
69 pub ty: String,
70 pub type_id: Option<SemanticId>,
71 pub type_ids: Vec<(String, SemanticId)>,
72}
73
74#[derive(Clone, Debug)]
75pub struct TaskExecutionBoundary {
76 pub id: SemanticId,
77 pub host_key: Option<SemanticId>,
78 pub name: String,
79 pub schedule: String,
80 pub statements: Vec<SemanticStatement>,
81 pub span: Span,
82}
83
84#[derive(Clone, Debug)]
85pub struct EndpointExecutionBoundary {
86 pub id: SemanticId,
87 pub kind: EndpointKind,
88 pub host_key: Option<SemanticId>,
89 pub name: String,
90 pub version: u32,
91 pub description: Option<String>,
92 pub method: Option<EndpointMethod>,
93 pub path: Option<String>,
94 pub inputs: Vec<EndpointExecutionInput>,
95 pub result: ExecutionResult,
96 pub statements: Vec<SemanticStatement>,
97 pub capabilities: Vec<String>,
98 pub timeout_ms: u64,
99 pub limit: Option<EndpointLimitPolicy>,
100 pub cache: Option<EndpointCachePolicy>,
101 pub idempotent: bool,
102 pub middleware: Vec<String>,
103 pub invalidates: Vec<SemanticId>,
104 pub span: Span,
105}
106
107#[derive(Clone, Debug)]
108pub struct EndpointExecutionInput {
109 pub id: SemanticId,
110 pub section: EndpointInputSection,
111 pub name: String,
112 pub ty: String,
113 pub type_id: Option<SemanticId>,
114 pub file: Option<FileUploadContract>,
115}
116
117#[derive(Clone, Debug)]
118pub struct ExecutionBoundary {
119 pub id: SemanticId,
120 pub action: SemanticId,
121 pub component: SemanticId,
122 pub component_name: String,
123 pub action_name: String,
124 pub target: ExecutionTarget,
125 pub parameters: Vec<ExecutionParameter>,
126 pub result: ExecutionResult,
127 pub body: Option<SemanticExpr>,
128 pub capabilities: Vec<String>,
129 pub route_scopes: Vec<ExecutionRouteScope>,
130 pub invalidates: Vec<SemanticId>,
131 pub span: Span,
132}
133
134#[derive(Clone, Debug)]
135pub struct ExecutionRouteScope {
136 pub route: SemanticId,
137 pub pattern: String,
138 pub parameters: Vec<ExecutionRouteParameter>,
139 pub middleware: Vec<SemanticId>,
140}
141
142#[derive(Clone, Debug)]
143pub struct ExecutionRouteParameter {
144 pub name: String,
145 pub ty: String,
146 pub catch_all: bool,
147}
148
149#[derive(Clone, Debug)]
150pub struct ExecutionParameter {
151 pub id: SemanticId,
152 pub name: String,
153 pub ty: String,
154 pub type_id: Option<SemanticId>,
155}
156
157#[derive(Clone, Debug)]
158pub struct ExecutionResult {
159 pub id: SemanticId,
160 pub ty: String,
161 pub type_id: Option<SemanticId>,
162}
163
164pub fn lower(program: &SemanticProgram) -> ExecutionProgram {
165 let mut lowered = lower_components(&program.components);
166 lowered.live_resources = program
167 .resources
168 .iter()
169 .filter(|resource| resource.live)
170 .map(|resource| LiveResourceExecutionContract {
171 id: resource.id.clone(),
172 name: resource.name.clone(),
173 capabilities: resource
174 .capabilities
175 .iter()
176 .map(|capability| capability.name.clone())
177 .collect(),
178 route_scopes: vec![],
179 })
180 .collect();
181 lowered.endpoints = program
182 .endpoints
183 .iter()
184 .map(|endpoint| EndpointExecutionBoundary {
185 id: endpoint.id.clone(),
186 kind: endpoint.kind,
187 host_key: match &endpoint.handler {
188 EndpointHandler::Host { key } => Some(key.clone()),
189 EndpointHandler::CompilerOwned { .. } => None,
190 },
191 name: endpoint.name.clone(),
192 version: endpoint.version,
193 description: endpoint.description.clone(),
194 method: endpoint.route.as_ref().map(|route| route.method),
195 path: endpoint.route.as_ref().map(|route| route.path.clone()),
196 inputs: endpoint
197 .params
198 .iter()
199 .chain(&endpoint.query)
200 .chain(&endpoint.body)
201 .map(|field| EndpointExecutionInput {
202 id: field.id.clone(),
203 section: field.section,
204 name: field.name.clone(),
205 ty: field.ty.to_string(),
206 type_id: field.type_id.clone(),
207 file: field.file.clone(),
208 })
209 .collect(),
210 result: ExecutionResult {
211 id: endpoint.result.id.clone(),
212 ty: endpoint.result.ty.to_string(),
213 type_id: endpoint.result.type_id.clone(),
214 },
215 statements: endpoint.handler.statements().to_vec(),
216 capabilities: endpoint
217 .capabilities
218 .iter()
219 .map(|capability| capability.name.clone())
220 .collect(),
221 timeout_ms: endpoint.timeout.milliseconds,
222 limit: endpoint.limit,
223 cache: endpoint.cache.clone(),
224 idempotent: endpoint.idempotent,
225 middleware: endpoint
226 .middleware
227 .iter()
228 .map(|middleware| middleware.name.clone())
229 .collect(),
230 invalidates: endpoint.invalidation.resources.clone(),
231 span: endpoint.span,
232 })
233 .collect();
234 lowered
235 .endpoints
236 .sort_by(|left, right| left.id.cmp(&right.id));
237 lowered.tasks = program
238 .tasks
239 .iter()
240 .map(|task| TaskExecutionBoundary {
241 id: task.id.clone(),
242 host_key: match &task.handler {
243 TaskHandler::Host { key } => Some(key.clone()),
244 TaskHandler::CompilerOwned { .. } => None,
245 },
246 name: task.name.clone(),
247 schedule: task.schedule.cron.clone(),
248 statements: task.handler.statements().to_vec(),
249 span: task.span,
250 })
251 .collect();
252 lowered.tasks.sort_by(|left, right| left.id.cmp(&right.id));
253 lowered.queues = program
254 .queues
255 .iter()
256 .map(|queue| QueueExecutionBoundary {
257 id: queue.id.clone(),
258 host_key: match &queue.handler {
259 QueueHandler::Host { key } => Some(key.clone()),
260 QueueHandler::CompilerOwned { .. } => None,
261 },
262 name: queue.name.clone(),
263 payload: queue
264 .payload
265 .iter()
266 .map(|field| QueueExecutionField {
267 id: field.id.clone(),
268 name: field.name.clone(),
269 ty: field.ty.to_string(),
270 type_id: field.type_id.clone(),
271 type_ids: field.type_ids.clone(),
272 })
273 .collect(),
274 retry: queue.retry,
275 backoff_ms: queue.backoff_ms,
276 statements: queue.handler.statements().to_vec(),
277 invalidates: queue.invalidation.resources.clone(),
278 span: queue.span,
279 })
280 .collect();
281 lowered.queues.sort_by(|left, right| left.id.cmp(&right.id));
282 lowered
283}
284
285pub fn lower_components<'a>(
286 components: impl IntoIterator<Item = &'a ComponentDefinition>,
287) -> ExecutionProgram {
288 let mut boundaries = Vec::new();
289 let mut presences = Vec::new();
290 for component in components {
291 if let Some(presence) = &component.presence {
292 presences.push(PresenceExecutionContract {
293 id: presence.id.clone(),
294 component: component.id.clone(),
295 component_name: component.name.clone(),
296 stream: presence.stream.clone(),
297 record_type: presence.record_type.clone(),
298 member_type: presence.member_type.clone(),
299 snapshot_type: presence.snapshot_type.clone(),
300 fields: presence
301 .fields
302 .iter()
303 .map(|field| PresenceExecutionField {
304 id: field.id.clone(),
305 name: field.name.clone(),
306 ty: field.ty.to_string(),
307 type_id: field.type_id.clone(),
308 })
309 .collect(),
310 capabilities: component
311 .capabilities
312 .iter()
313 .map(|capability| capability.name.clone())
314 .collect(),
315 route_scopes: vec![],
316 ttl_ms: presence.ttl_milliseconds,
317 heartbeat_ms: presence.heartbeat_milliseconds,
318 });
319 }
320 for action in component
321 .actions
322 .iter()
323 .filter(|action| action.execution.is_remote())
324 {
325 boundaries.push(ExecutionBoundary {
326 id: SemanticId::execution_boundary(&component.name, &action.name, action.execution),
327 action: action.id.clone(),
328 component: component.id.clone(),
329 component_name: component.name.clone(),
330 action_name: action.name.clone(),
331 target: action.execution,
332 parameters: action
333 .parameters
334 .iter()
335 .map(|parameter| ExecutionParameter {
336 id: parameter.id.clone(),
337 name: parameter.name.clone(),
338 ty: parameter.ty.to_string(),
339 type_id: parameter.type_id.clone(),
340 })
341 .collect(),
342 result: ExecutionResult {
343 id: action.result.id.clone(),
344 ty: action.result.ty.to_string(),
345 type_id: action.result.type_id.clone(),
346 },
347 body: action
348 .statements
349 .iter()
350 .find_map(|statement| match statement {
351 SemanticStatement::Return { value, .. } => Some(value.clone()),
352 SemanticStatement::Assignment { .. }
353 | SemanticStatement::FieldAssignment { .. }
354 | SemanticStatement::Local { .. }
355 | SemanticStatement::LocalAssignment { .. }
356 | SemanticStatement::RemoteAwait { .. }
357 | SemanticStatement::If { .. }
358 | SemanticStatement::ActionCall { .. }
359 | SemanticStatement::Transition { .. }
360 | SemanticStatement::CollectionMutation { .. }
361 | SemanticStatement::PrincipalMatch { .. }
366 | SemanticStatement::Emit { .. } => None,
367 }),
368 capabilities: action
369 .capabilities
370 .iter()
371 .map(|capability| capability.name.clone())
372 .collect(),
373 route_scopes: vec![],
374 invalidates: action.invalidation.resources.clone(),
375 span: action.span,
376 });
377 }
378 }
379 boundaries.sort_by(|left, right| left.id.cmp(&right.id));
380 boundaries.dedup_by(|left, right| left.id == right.id);
381 ExecutionProgram {
382 live_resources: vec![],
383 presences,
384 boundaries,
385 endpoints: vec![],
386 tasks: vec![],
387 queues: vec![],
388 }
389}
390
391impl ExecutionProgram {
392 pub fn to_json(&self) -> String {
393 format!(
394 "{{\n \"schemaVersion\": {SCHEMA_VERSION},\n \"liveResources\": [{}],\n \"presences\": [{}],\n \"boundaries\": [{}],\n \"endpoints\": [{}],\n \"tasks\": [{}],\n \"queues\": [{}]\n}}",
395 self.live_resources
396 .iter()
397 .map(|resource| {
398 format!(
399 "{{\"id\":\"{}\",\"name\":\"{}\",\"capabilities\":[{}],\"routeScopes\":[{}]}}",
400 resource.id,
401 json_escape(&resource.name),
402 resource
403 .capabilities
404 .iter()
405 .map(|capability| format!("\"{}\"", json_escape(capability)))
406 .collect::<Vec<_>>()
407 .join(","),
408 resource
409 .route_scopes
410 .iter()
411 .map(ExecutionRouteScope::to_json)
412 .collect::<Vec<_>>()
413 .join(","),
414 )
415 })
416 .collect::<Vec<_>>()
417 .join(","),
418 self.presences
419 .iter()
420 .map(PresenceExecutionContract::to_json)
421 .collect::<Vec<_>>()
422 .join(","),
423 self.boundaries
424 .iter()
425 .map(ExecutionBoundary::to_json)
426 .collect::<Vec<_>>()
427 .join(","),
428 self.endpoints
429 .iter()
430 .map(EndpointExecutionBoundary::to_json)
431 .collect::<Vec<_>>()
432 .join(","),
433 self.tasks
434 .iter()
435 .map(TaskExecutionBoundary::to_json)
436 .collect::<Vec<_>>()
437 .join(","),
438 self.queues
439 .iter()
440 .map(QueueExecutionBoundary::to_json)
441 .collect::<Vec<_>>()
442 .join(",")
443 )
444 }
445}
446
447impl PresenceExecutionContract {
448 fn to_json(&self) -> String {
449 format!(
450 "{{\"id\":\"{}\",\"component\":\"{}\",\"componentName\":\"{}\",\"stream\":\"{}\",\"recordType\":\"{}\",\"memberType\":\"{}\",\"snapshotType\":\"{}\",\"fields\":[{}],\"capabilities\":[{}],\"routeScopes\":[{}],\"ttlMs\":{},\"heartbeatMs\":{}}}",
451 self.id,
452 self.component,
453 json_escape(&self.component_name),
454 self.stream,
455 self.record_type,
456 self.member_type,
457 self.snapshot_type,
458 self.fields
459 .iter()
460 .map(PresenceExecutionField::to_json)
461 .collect::<Vec<_>>()
462 .join(","),
463 self.capabilities
464 .iter()
465 .map(|capability| format!("\"{}\"", json_escape(capability)))
466 .collect::<Vec<_>>()
467 .join(","),
468 self.route_scopes
469 .iter()
470 .map(ExecutionRouteScope::to_json)
471 .collect::<Vec<_>>()
472 .join(","),
473 self.ttl_ms,
474 self.heartbeat_ms,
475 )
476 }
477}
478
479impl PresenceExecutionField {
480 fn to_json(&self) -> String {
481 format!(
482 "{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
483 self.id,
484 json_escape(&self.name),
485 json_escape(&self.ty),
486 self.type_id
487 .as_ref()
488 .map(|id| format!("\"{id}\""))
489 .unwrap_or_else(|| "null".into()),
490 )
491 }
492}
493
494impl QueueExecutionBoundary {
495 fn to_json(&self) -> String {
496 format!(
497 "{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"payload\":[{}],\"retry\":{},\"backoffMs\":{},\"invalidates\":{},\"statements\":[{}],\"span\":{{\"start\":{},\"end\":{}}}}}",
498 self.id,
499 optional_id_json(&self.host_key),
500 json_escape(&self.name),
501 self.payload
502 .iter()
503 .map(QueueExecutionField::to_json)
504 .collect::<Vec<_>>()
505 .join(","),
506 self.retry,
507 self.backoff_ms,
508 ids_json(&self.invalidates),
509 self.statements
510 .iter()
511 .map(SemanticStatement::to_json)
512 .collect::<Vec<_>>()
513 .join(","),
514 self.span.start,
515 self.span.end,
516 )
517 }
518}
519
520impl QueueExecutionField {
521 fn to_json(&self) -> String {
522 format!(
523 "{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{},\"typeIds\":[{}]}}",
524 self.id,
525 json_escape(&self.name),
526 json_escape(&self.ty),
527 optional_id_json(&self.type_id),
528 self.type_ids
529 .iter()
530 .map(|(name, id)| format!(
531 "{{\"name\":\"{}\",\"id\":\"{}\"}}",
532 json_escape(name),
533 id
534 ))
535 .collect::<Vec<_>>()
536 .join(","),
537 )
538 }
539}
540
541impl TaskExecutionBoundary {
542 fn to_json(&self) -> String {
543 format!(
544 "{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"schedule\":\"{}\",\"statements\":[{}],\"span\":{{\"start\":{},\"end\":{}}}}}",
545 self.id,
546 optional_id_json(&self.host_key),
547 json_escape(&self.name),
548 json_escape(&self.schedule),
549 self.statements
550 .iter()
551 .map(SemanticStatement::to_json)
552 .collect::<Vec<_>>()
553 .join(","),
554 self.span.start,
555 self.span.end,
556 )
557 }
558}
559
560impl EndpointExecutionBoundary {
561 fn to_json(&self) -> String {
562 format!(
563 "{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"version\":{},\"description\":{},\"kind\":\"{}\",\"method\":{},\"path\":{},\"inputs\":[{}],\"result\":{},\"statements\":[{}],\"capabilities\":[{}],\"timeoutMs\":{},\"limit\":{},\"cache\":{},\"idempotent\":{},\"middleware\":[{}],\"invalidates\":{},\"span\":{{\"start\":{},\"end\":{}}}}}",
564 self.id,
565 optional_id_json(&self.host_key),
566 json_escape(&self.name),
567 self.version,
568 self.description
569 .as_ref()
570 .map(|description| format!("\"{}\"", json_escape(description)))
571 .unwrap_or_else(|| "null".into()),
572 self.kind.as_str(),
573 self.method
574 .map(|method| format!("\"{}\"", method.as_str()))
575 .unwrap_or_else(|| "null".into()),
576 self.path
577 .as_ref()
578 .map(|path| format!("\"{}\"", json_escape(path)))
579 .unwrap_or_else(|| "null".into()),
580 self.inputs
581 .iter()
582 .map(EndpointExecutionInput::to_json)
583 .collect::<Vec<_>>()
584 .join(","),
585 self.result.to_json(),
586 self.statements
587 .iter()
588 .map(SemanticStatement::to_json)
589 .collect::<Vec<_>>()
590 .join(","),
591 self.capabilities
592 .iter()
593 .map(|capability| format!("\"{}\"", json_escape(capability)))
594 .collect::<Vec<_>>()
595 .join(","),
596 self.timeout_ms,
597 self.limit
598 .map(|limit| format!(
599 "{{\"requests\":{},\"window\":\"{}\",\"scope\":\"{}\"}}",
600 limit.requests,
601 limit.window.as_str(),
602 limit.scope.as_str()
603 ))
604 .unwrap_or_else(|| "null".into()),
605 self.cache
606 .as_ref()
607 .map(|cache| format!(
608 "{{\"id\":\"{}\",\"mode\":\"{}\",\"seconds\":{},\"tags\":[{}]}}",
609 cache.id,
610 cache.mode.as_str(),
611 cache.seconds,
612 cache
613 .tags
614 .iter()
615 .map(|tag| format!("\"{}\"", json_escape(tag)))
616 .collect::<Vec<_>>()
617 .join(",")
618 ))
619 .unwrap_or_else(|| "null".into()),
620 self.idempotent,
621 self.middleware
622 .iter()
623 .map(|middleware| format!("\"{}\"", json_escape(middleware)))
624 .collect::<Vec<_>>()
625 .join(","),
626 ids_json(&self.invalidates),
627 self.span.start,
628 self.span.end,
629 )
630 }
631}
632
633impl EndpointExecutionInput {
634 fn to_json(&self) -> String {
635 format!(
636 "{{\"id\":\"{}\",\"section\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{},\"file\":{}}}",
637 self.id,
638 self.section.as_str(),
639 json_escape(&self.name),
640 json_escape(&self.ty),
641 optional_id_json(&self.type_id),
642 self.file
643 .as_ref()
644 .map(FileUploadContract::to_json)
645 .unwrap_or_else(|| "null".into()),
646 )
647 }
648}
649
650impl ExecutionBoundary {
651 fn to_json(&self) -> String {
652 format!(
653 "{{\"id\":\"{}\",\"action\":\"{}\",\"component\":\"{}\",\"componentName\":\"{}\",\"actionName\":\"{}\",\"target\":\"{}\",\"parameters\":[{}],\"result\":{},\"body\":{},\"capabilities\":[{}],\"routeScopes\":[{}],\"invalidates\":{},\"span\":{{\"start\":{},\"end\":{}}}}}",
654 self.id,
655 self.action,
656 self.component,
657 json_escape(&self.component_name),
658 json_escape(&self.action_name),
659 self.target.as_str(),
660 self.parameters
661 .iter()
662 .map(ExecutionParameter::to_json)
663 .collect::<Vec<_>>()
664 .join(","),
665 self.result.to_json(),
666 self.body
667 .as_ref()
668 .map(|body| body.to_json())
669 .unwrap_or_else(|| "null".into()),
670 self.capabilities
671 .iter()
672 .map(|capability| format!("\"{}\"", json_escape(capability)))
673 .collect::<Vec<_>>()
674 .join(","),
675 self.route_scopes
676 .iter()
677 .map(ExecutionRouteScope::to_json)
678 .collect::<Vec<_>>()
679 .join(","),
680 ids_json(&self.invalidates),
681 self.span.start,
682 self.span.end,
683 )
684 }
685}
686
687fn ids_json(ids: &[SemanticId]) -> String {
688 format!(
689 "[{}]",
690 ids.iter()
691 .map(|id| format!("\"{id}\""))
692 .collect::<Vec<_>>()
693 .join(",")
694 )
695}
696
697impl ExecutionRouteScope {
698 fn to_json(&self) -> String {
699 format!(
700 "{{\"route\":\"{}\",\"pattern\":\"{}\",\"parameters\":[{}],\"middleware\":[{}]}}",
701 self.route,
702 json_escape(&self.pattern),
703 self.parameters
704 .iter()
705 .map(|parameter| format!(
706 "{{\"name\":\"{}\",\"type\":\"{}\",\"catchAll\":{}}}",
707 json_escape(¶meter.name),
708 json_escape(¶meter.ty),
709 parameter.catch_all,
710 ))
711 .collect::<Vec<_>>()
712 .join(","),
713 self.middleware
714 .iter()
715 .map(|id| format!("\"{}\"", id))
716 .collect::<Vec<_>>()
717 .join(","),
718 )
719 }
720}
721
722impl ExecutionResult {
723 fn to_json(&self) -> String {
724 format!(
725 "{{\"id\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
726 self.id,
727 json_escape(&self.ty),
728 optional_id_json(&self.type_id),
729 )
730 }
731}
732
733impl ExecutionParameter {
734 fn to_json(&self) -> String {
735 format!(
736 "{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
737 self.id,
738 json_escape(&self.name),
739 json_escape(&self.ty),
740 optional_id_json(&self.type_id),
741 )
742 }
743}
744
745fn optional_id_json(id: &Option<SemanticId>) -> String {
746 id.as_ref()
747 .map(|id| format!("\"{}\"", json_escape(&id.to_string())))
748 .unwrap_or_else(|| "null".into())
749}
750
751#[cfg(test)]
752mod tests {
753 use std::process::Command;
754
755 use super::*;
756
757 fn endpoint_boundary(name: &str, host_key: Option<SemanticId>) -> EndpointExecutionBoundary {
758 EndpointExecutionBoundary {
759 id: SemanticId::endpoint(name),
760 kind: EndpointKind::RequestResponse,
761 host_key,
762 name: name.into(),
763 version: 1,
764 description: None,
765 method: None,
766 path: None,
767 inputs: vec![],
768 result: ExecutionResult {
769 id: SemanticId::endpoint_result(name),
770 ty: "String".into(),
771 type_id: None,
772 },
773 statements: vec![],
774 capabilities: vec![],
775 timeout_ms: 30_000,
776 limit: None,
777 cache: None,
778 idempotent: false,
779 middleware: vec![],
780 invalidates: vec![],
781 span: Span::default(),
782 }
783 }
784
785 #[test]
786 fn endpoint_host_keys_are_json_strings_or_null() {
787 let program = ExecutionProgram {
788 live_resources: vec![],
789 presences: vec![],
790 boundaries: vec![],
791 endpoints: vec![
792 endpoint_boundary("LoadProgress", Some(SemanticId::endpoint("LoadProgress"))),
793 endpoint_boundary("AuthCallback", None),
794 ],
795 tasks: vec![],
796 queues: vec![],
797 };
798
799 let json = program.to_json();
800 assert!(json.contains(
801 "\"id\":\"endpoint:LoadProgress@1\",\"hostKey\":\"endpoint:LoadProgress@1\""
802 ));
803 assert!(json.contains("\"id\":\"endpoint:AuthCallback@1\",\"hostKey\":null"));
804 assert!(!json.contains("\"hostKey\":\"null\""));
805 assert!(!json.contains("\"hostKey\":\"\""));
806
807 let parsed = Command::new("node")
808 .args(["-e", "JSON.parse(process.argv[1])", &json])
809 .status()
810 .expect("Node.js is required by the workspace test gate");
811 assert!(parsed.success(), "execution manifest must be valid JSON");
812 }
813}