1use std::sync::Arc;
6
7use axum::extract::{Path, State};
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::routing::{get, post};
11use axum::{Json, Router};
12
13use crate::config::PolicyConfig;
14use crate::state::SharedState;
15
16pub fn router() -> Router<Arc<SharedState>> {
18 Router::new()
19 .route("/api/policies", get(list_policies))
20 .route("/api/policies/validate", post(validate_policy))
21 .route(
22 "/api/policies/{name}",
23 get(get_policy).put(update_policy).delete(delete_policy),
24 )
25 .route("/api/plugins", get(list_plugin_types))
26 .route("/api/scripts", get(list_scripts))
27}
28
29async fn list_policies(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
31 let gw = state.gateway.read().await;
32 Json(&gw.policies).into_response()
33}
34
35async fn get_policy(
39 State(state): State<Arc<SharedState>>,
40 Path(name): Path<String>,
41) -> impl IntoResponse {
42 let gw = state.gateway.read().await;
43 match gw.policies.iter().find(|p| p.name == name) {
44 Some(policy) => Json(policy).into_response(),
45 None => (
46 StatusCode::NOT_FOUND,
47 Json(serde_json::json!({"error": "not_found"})),
48 )
49 .into_response(),
50 }
51}
52
53async fn update_policy(
60 State(state): State<Arc<SharedState>>,
61 Path(name): Path<String>,
62 Json(mut policy): Json<PolicyConfig>,
63) -> impl IntoResponse {
64 policy.name = name.clone();
65 let candidate = {
66 let gw = state.gateway.read().await;
67 let mut candidate = gw.clone();
68 if let Some(existing) = candidate.policies.iter_mut().find(|p| p.name == name) {
69 *existing = policy;
70 } else {
71 candidate.policies.push(policy);
72 }
73 candidate
74 };
75
76 match state.config_store.clone().commit(&state, candidate).await {
77 Ok(_) => Json(serde_json::json!({"status": "updated"})).into_response(),
78 Err(e) => (
79 StatusCode::BAD_REQUEST,
80 Json(serde_json::json!({"error": e})),
81 )
82 .into_response(),
83 }
84}
85
86async fn delete_policy(
92 State(state): State<Arc<SharedState>>,
93 Path(name): Path<String>,
94) -> impl IntoResponse {
95 let candidate = {
96 let gw = state.gateway.read().await;
97 let mut candidate = gw.clone();
98 let before = candidate.policies.len();
99 candidate.policies.retain(|p| p.name != name);
100 if candidate.policies.len() == before {
101 return (
102 StatusCode::NOT_FOUND,
103 Json(serde_json::json!({"error": "not_found"})),
104 )
105 .into_response();
106 }
107 candidate
108 };
109
110 match state.config_store.clone().commit(&state, candidate).await {
111 Ok(_) => Json(serde_json::json!({"status": "deleted"})).into_response(),
112 Err(e) => (
113 StatusCode::BAD_REQUEST,
114 Json(serde_json::json!({"error": e})),
115 )
116 .into_response(),
117 }
118}
119
120async fn validate_policy(
133 State(state): State<Arc<SharedState>>,
134 Json(mut raw): Json<serde_json::Value>,
135) -> impl IntoResponse {
136 if let Some(obj) = raw.as_object_mut() {
137 obj.entry("name")
138 .or_insert_with(|| serde_json::Value::String("unsaved-policy".to_string()));
139 }
140 let policy: PolicyConfig = match serde_json::from_value(raw) {
141 Ok(p) => p,
142 Err(e) => {
143 return Json(serde_json::json!({
144 "valid": false,
145 "errors": [e.to_string()],
146 "buffering": []
147 }))
148 .into_response();
149 }
150 };
151
152 let (supernodes, plugin_configs) = {
153 let gw = state.gateway.read().await;
154 (gw.supernodes.clone(), gw.plugin_configs.clone())
155 };
156
157 let compiled = crate::graph::prepare_policy(policy, &supernodes, &plugin_configs)
158 .and_then(|p| crate::graph::compile_policy(&p, state.resources.clone()));
159
160 let (errors, buffering, cache_pairs): (Vec<String>, serde_json::Value, serde_json::Value) =
161 match compiled {
162 Ok(graph) => (
163 Vec::new(),
164 serde_json::to_value(graph.buffering_reasons())
165 .expect("BufferingReason always serializes"),
166 serde_json::to_value(graph.cache_pair_warnings())
167 .expect("CachePairWarning always serializes"),
168 ),
169 Err(e) => (
170 e.split("; ").map(str::to_string).collect(),
171 serde_json::json!([]),
172 serde_json::json!([]),
173 ),
174 };
175
176 Json(serde_json::json!({
177 "valid": errors.is_empty(),
178 "errors": errors,
179 "buffering": buffering,
180 "cache_pairs": cache_pairs
181 }))
182 .into_response()
183}
184
185async fn list_scripts(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
194 let mut scripts = Vec::new();
195
196 let config_path = state
198 .config_path
199 .as_deref()
200 .unwrap_or(std::path::Path::new("config"));
201 let plugins_dir = config_path
202 .parent()
203 .unwrap_or(std::path::Path::new("."))
204 .join("plugins");
205
206 if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
207 for entry in entries.flatten() {
208 let path = entry.path();
209 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
210 let name = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
211 let runtime = match ext {
212 "lua" => "lua",
213 _ => continue,
214 };
215 scripts.push(serde_json::json!({
216 "name": name,
217 "file": path.to_string_lossy(),
218 "runtime": runtime,
219 }));
220 }
221 }
222
223 Json(serde_json::json!({ "scripts": scripts }))
224}
225
226async fn list_plugin_types() -> impl IntoResponse {
236 Json(serde_json::json!({ "plugins": plugin_catalog() }))
237}
238
239pub(crate) fn plugin_catalog() -> Vec<serde_json::Value> {
241 const CATALOG: &[(&str, &str)] = &[
242 ("listener", "Route entry point — receives incoming request"),
244 ("client", "Route exit point — sends response to client"),
245 (
246 "condition",
247 "Branch the policy on boolean conditions (true/false ports)",
248 ),
249 ("upstream", "Forward to a load-balanced backend pool"),
250 ("proxy-rewrite", "Rewrite path, add/remove headers"),
251 (
252 "response-rewrite",
253 "Rewrite response status, headers, and body",
254 ),
255 (
256 "body-transformer",
257 "Rewrite request/response JSON bodies via templates",
258 ),
259 (
260 "set-vars",
261 "Derive variables from the context (templates, JSONPath, regex captures)",
262 ),
263 (
264 "degraphql",
265 "Expose a REST endpoint backed by a GraphQL upstream",
266 ),
267 ("redirect", "HTTP redirect, or force HTTP→HTTPS"),
268 ("echo", "Wrap or replace the response body (demo/testing)"),
269 ("gzip", "Compress the response body with gzip"),
270 ("brotli", "Compress the response body with Brotli"),
271 ("request-id", "Attach a unique request-id header"),
272 (
273 "real-ip",
274 "Recover the client IP from a trusted proxy header",
275 ),
276 ("error-handler", "Custom error responses"),
278 (
279 "error-page",
280 "Replace 404/500/502/503 bodies with configured pages",
281 ),
282 (
283 "exit-transformer",
284 "Remap status and body of gateway-generated exits",
285 ),
286 (
287 "mocking",
288 "Respond with a configured mock instead of proxying",
289 ),
290 ("cors", "CORS header management"),
292 ("csrf", "Double-submit CSRF token validation"),
293 ("ip-restriction", "Allow/deny by IP/CIDR"),
294 ("ua-restriction", "Allow/deny by User-Agent regex"),
295 ("referer-restriction", "Allow/deny by Referer host"),
296 ("uri-blocker", "Block requests matching URI regex rules"),
297 ("request-size-limit", "Reject oversized requests"),
298 (
299 "request-validation",
300 "Validate headers/body against JSON Schema",
301 ),
302 (
303 "data-mask",
304 "Mask sensitive fields in bodies, headers, query",
305 ),
306 ("rate-limit", "Token bucket rate limiting"),
308 ("limit-count", "Fixed-window request-count limiting"),
309 (
310 "limit-conn",
311 "Concurrent-request limiting (acquire/release pair)",
312 ),
313 ("api-breaker", "Circuit breaker on unhealthy upstreams"),
314 ("traffic-split", "Weighted / conditional traffic steering"),
315 ("proxy-mirror", "Fire-and-forget clone to a shadow upstream"),
316 (
317 "proxy-cache",
318 "Cache upstream responses (lookup/store pair, plus a purge phase)",
319 ),
320 ("fault-injection", "Inject delays and abort responses"),
321 (
322 "workflow",
323 "Ordered rules — reject or rate-limit the first match",
324 ),
325 (
326 "traffic-label",
327 "Tag matching requests with headers and labels",
328 ),
329 ("key-auth", "API key authentication"),
331 ("basic-auth", "HTTP Basic authentication"),
332 ("jwt-auth", "JWT validation"),
333 (
334 "hmac-auth",
335 "HMAC request signing (access key / secret key)",
336 ),
337 ("jwe-decrypt", "Decrypt a JWE token into a forwarded header"),
338 (
339 "multi-auth",
340 "Chain auth plugins — accept the first that succeeds",
341 ),
342 ("ldap-auth", "Authenticate Basic credentials against LDAP"),
343 (
344 "consumer-restriction",
345 "Allow/deny by consumer name or group",
346 ),
347 ("acl", "Allow/deny by consumer group"),
348 (
349 "attach-consumer-label",
350 "Copy consumer labels into upstream headers",
351 ),
352 (
354 "forward-auth",
355 "Delegate the decision to an external HTTP service",
356 ),
357 ("opa", "Delegate authorization to Open Policy Agent"),
358 ("authz-casbin", "Embedded Casbin RBAC/ABAC enforcement"),
359 ("authz-keycloak", "Keycloak UMA permission check"),
360 (
361 "authz-casdoor",
362 "Casdoor introspection or interactive OAuth login",
363 ),
364 (
365 "openid-connect",
366 "OIDC bearer validation or interactive login",
367 ),
368 ("cas-auth", "CAS ticket validation or interactive SSO login"),
369 ("wolf-rbac", "Wolf RBAC token check"),
370 (
371 "dingtalk-auth",
372 "DingTalk code/token validation with optional session mode",
373 ),
374 (
375 "feishu-auth",
376 "Feishu/Lark code/token validation with optional session mode",
377 ),
378 (
380 "serverless-pre-function",
381 "Run inline Lua before the upstream",
382 ),
383 (
384 "serverless-post-function",
385 "Run inline Lua after the upstream",
386 ),
387 (
388 "oas-validator",
389 "Validate requests against an OpenAPI 3 spec",
390 ),
391 ("aws-lambda", "Invoke an AWS Lambda function"),
392 ("azure-functions", "Invoke an Azure Function"),
393 ("openwhisk", "Invoke an Apache OpenWhisk action"),
394 ("openfunction", "Invoke an OpenFunction function"),
395 ("logging", "Structured access logging"),
397 ("http-logger", "Ship logs to an HTTP endpoint"),
398 ("tcp-logger", "Ship logs over a raw TCP socket"),
399 ("udp-logger", "Ship logs over a raw UDP socket"),
400 ("syslog", "Ship logs via syslog (RFC 5424)"),
401 ("file-logger", "Append logs to a local file"),
402 (
403 "error-log-logger",
404 "Ship request-level errors to a TCP sink",
405 ),
406 ("elasticsearch-logger", "Bulk-index logs into Elasticsearch"),
407 ("clickhouse-logger", "Insert logs into ClickHouse"),
408 ("loki-logger", "Push logs to Grafana Loki"),
409 ("splunk-hec-logging", "Ship logs to Splunk HEC"),
410 ("datadog", "Emit DogStatsD metrics to the Datadog agent"),
411 ("loggly", "Ship logs to SolarWinds Loggly"),
412 ("google-cloud-logging", "Ship logs to Google Cloud Logging"),
413 ("sls-logger", "Ship logs to Alibaba Cloud SLS"),
414 ("tencent-cloud-cls", "Ship logs to Tencent Cloud CLS"),
415 ("skywalking-logger", "Ship logs to Apache SkyWalking"),
416 ("lago", "Meter requests as Lago billing events"),
417 ("prometheus", "Per-consumer request counters"),
419 ("opentelemetry", "OTLP/HTTP trace export (W3C traceparent)"),
420 ("zipkin", "Zipkin v2 trace export (B3 propagation)"),
421 ("skywalking", "SkyWalking segment export (sw8 propagation)"),
422 ("script", "Custom plugin logic written in Lua"),
424 (
426 "store-get",
427 "Read a key from a shared store into context.message (miss port when absent)",
428 ),
429 (
430 "store-set",
431 "Write a key into a shared store, with an optional TTL",
432 ),
433 (
434 "store-delete",
435 "Remove a key from a shared store (idempotent)",
436 ),
437 (
438 "store-incr",
439 "Atomically increment a counter in a shared store (TTL set at creation)",
440 ),
441 ];
442
443 CATALOG
444 .iter()
445 .map(|(t, d)| {
446 let spec = crate::plugins::port_spec(t).expect("catalog type is registered");
452 serde_json::json!({
453 "type": t,
454 "description": d,
455 "ports": serde_json::to_value(spec).unwrap()
456 })
457 })
458 .collect()
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::config::{GatewayConfig, SystemConfig};
465 use crate::config_store::FileConfigStore;
466 use axum::body::Body;
467 use axum::http::Request;
468 use tower::ServiceExt;
469
470 fn test_state(gateway_yaml: &str) -> Arc<SharedState> {
471 let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
472 let gateway: GatewayConfig = serde_yaml::from_str(gateway_yaml).unwrap();
473 Arc::new(
474 SharedState::new(
475 system,
476 gateway,
477 None,
478 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
479 "gateway.yaml",
480 ))),
481 )
482 .unwrap(),
483 )
484 }
485
486 fn app(state: Arc<SharedState>) -> Router {
487 router().with_state(state)
488 }
489
490 async fn validate_policy_json(body: serde_json::Value) -> serde_json::Value {
493 let state = test_state("{}");
494 let resp = app(state)
495 .oneshot(
496 Request::post("/api/policies/validate")
497 .header("content-type", "application/json")
498 .body(Body::from(body.to_string()))
499 .unwrap(),
500 )
501 .await
502 .unwrap();
503 assert_eq!(resp.status(), StatusCode::OK);
504 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
505 .await
506 .unwrap();
507 serde_json::from_slice(&bytes).unwrap()
508 }
509
510 #[tokio::test]
514 async fn test_validate_reports_forced_buffering() {
515 let body = validate_policy_json(serde_json::json!({
516 "nodes": [
517 { "id": "listener", "type": "listener", "config": {} },
518 { "id": "up", "type": "upstream",
519 "config": { "targets": [{ "host": "h", "port": 80 }] } },
520 { "id": "rw", "type": "response-rewrite",
521 "config": { "filters": [{ "regex": "a", "replace": "b" }] } },
522 { "id": "client", "type": "client", "config": {} }
523 ],
524 "edges": [
525 { "from": "listener.out", "to": "up.in" },
526 { "from": "up.success", "to": "rw.in" },
527 { "from": "rw.success", "to": "client.in" }
528 ]
529 }))
530 .await;
531
532 assert_eq!(body["valid"], serde_json::json!(true));
533 assert_eq!(body["buffering"][0]["upstream"], serde_json::json!("up"));
534 assert_eq!(body["buffering"][0]["blocked_by"], serde_json::json!("rw"));
535 }
536
537 fn factory_types() -> Vec<String> {
542 include_str!("../plugins/mod.rs")
543 .lines()
544 .filter_map(|line| {
545 let line = line.trim();
546 let rest = line.strip_prefix('"')?;
547 let (name, tail) = rest.split_once('"')?;
548 tail.trim_start()
549 .starts_with("=>")
550 .then(|| name.to_string())
551 })
552 .collect()
553 }
554
555 #[test]
560 fn test_catalog_covers_factory() {
561 let catalog: Vec<String> = plugin_catalog()
562 .iter()
563 .map(|p| p["type"].as_str().unwrap().to_string())
564 .collect();
565
566 let missing: Vec<_> = factory_types()
567 .iter()
568 .filter(|t| !catalog.contains(t))
569 .cloned()
570 .collect();
571 assert!(
572 missing.is_empty(),
573 "registered plugins missing from the UI catalog: {missing:?}"
574 );
575 }
576
577 #[test]
580 fn test_catalog_has_no_unknown_types() {
581 let factory = factory_types();
582 let unknown: Vec<_> = plugin_catalog()
583 .iter()
584 .map(|p| p["type"].as_str().unwrap().to_string())
585 .filter(|t| !factory.contains(t))
586 .collect();
587 assert!(
588 unknown.is_empty(),
589 "catalog advertises types create_plugin cannot build: {unknown:?}"
590 );
591 }
592
593 #[test]
594 fn test_catalog_has_no_duplicates() {
595 let mut seen = std::collections::HashSet::new();
596 for p in plugin_catalog() {
597 let t = p["type"].as_str().unwrap().to_string();
598 assert!(seen.insert(t.clone()), "duplicate catalog entry: {t}");
599 }
600 }
601
602 fn types_with_an_icon() -> Vec<String> {
605 include_str!("../../ui/src/pluginMeta.tsx")
606 .lines()
607 .filter_map(|line| {
608 let line = line.trim();
609 if !line.contains("color:") || !line.contains("icon:") {
613 return None;
614 }
615 let (key, _) = line.split_once(':')?;
616 let key = key.trim().trim_matches('\'');
617 let plausible = !key.is_empty()
618 && key
619 .chars()
620 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
621 plausible.then(|| key.to_string())
622 })
623 .collect()
624 }
625
626 #[test]
630 fn test_every_catalog_plugin_has_an_icon() {
631 let with_icon = types_with_an_icon();
632 let missing: Vec<_> = plugin_catalog()
633 .iter()
634 .map(|p| p["type"].as_str().unwrap().to_string())
635 .filter(|t| !with_icon.contains(t))
636 .collect();
637 assert!(
638 missing.is_empty(),
639 "plugins with no icon in ui/src/pluginMeta.tsx (they fall back to the generic cube): {missing:?}"
640 );
641 }
642
643 fn types_in_a_palette_category() -> Vec<String> {
647 include_str!("../../ui/src/pluginCategories.ts")
648 .split('\'')
649 .skip(1)
651 .step_by(2)
652 .filter(|t| {
653 !t.is_empty()
654 && t.chars()
655 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
656 })
657 .map(str::to_string)
658 .collect()
659 }
660
661 #[test]
665 fn test_every_catalog_plugin_is_in_a_palette_category() {
666 let categorised = types_in_a_palette_category();
667 let missing: Vec<_> = plugin_catalog()
668 .iter()
669 .map(|p| p["type"].as_str().unwrap().to_string())
670 .filter(|t| !matches!(t.as_str(), "listener" | "client" | "script"))
673 .filter(|t| !categorised.contains(t))
674 .collect();
675 assert!(
676 missing.is_empty(),
677 "plugins missing from ui/src/pluginCategories.ts (they fall into the palette's 'Other' group): {missing:?}"
678 );
679 }
680
681 #[test]
686 fn test_every_catalog_plugin_has_a_docs_page() {
687 let pages: std::collections::HashSet<String> = std::fs::read_dir(concat!(
688 env!("CARGO_MANIFEST_DIR"),
689 "/website/docs/reference/plugins"
690 ))
691 .expect("plugin docs directory")
692 .filter_map(|e| {
693 let name = e.ok()?.file_name().to_string_lossy().to_string();
694 name.strip_suffix(".md").map(str::to_string)
695 })
696 .collect();
697 let missing: Vec<_> = plugin_catalog()
698 .iter()
699 .map(|p| p["type"].as_str().unwrap().to_string())
700 .filter(|t| t != "listener" && t != "client")
702 .filter(|t| !pages.contains(t))
703 .collect();
704 assert!(
705 missing.is_empty(),
706 "plugins with no website/docs/reference/plugins/<type>.md page (get_node_type returns no docs): {missing:?}"
707 );
708 }
709
710 #[test]
712 fn test_every_plugin_docs_page_is_in_the_sidebar() {
713 let sidebar = include_str!("../../website/sidebars.ts");
714 let missing: Vec<_> = plugin_catalog()
715 .iter()
716 .map(|p| p["type"].as_str().unwrap().to_string())
717 .filter(|t| t != "listener" && t != "client")
718 .filter(|t| !sidebar.contains(&format!("reference/plugins/{t}'")))
719 .collect();
720 assert!(
721 missing.is_empty(),
722 "plugin docs pages missing from website/sidebars.ts: {missing:?}"
723 );
724 }
725
726 #[test]
728 fn test_catalog_entries_carry_ports() {
729 for p in plugin_catalog() {
730 let ty = p["type"].as_str().unwrap();
731 let ports = &p["ports"];
732 assert!(
733 ports["outputs"].is_array(),
734 "'{ty}' catalog entry lacks ports.outputs"
735 );
736 let spec = crate::plugins::port_spec(ty).unwrap();
737 let names: Vec<&str> = ports["outputs"]
738 .as_array()
739 .unwrap()
740 .iter()
741 .map(|o| o["name"].as_str().unwrap())
742 .collect();
743 assert_eq!(
744 names,
745 spec.outputs.iter().map(|o| o.name).collect::<Vec<_>>()
746 );
747 }
748 let cors = plugin_catalog()
750 .into_iter()
751 .find(|p| p["type"] == "cors")
752 .unwrap();
753 assert_eq!(cors["ports"]["outputs"][1]["kind"], "outcome");
754 }
755}