pingap_core/plugin.rs
1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use super::{Ctx, HttpResponse};
16use ahash::AHashMap;
17use async_trait::async_trait;
18use pingora::http::ResponseHeader;
19use pingora::proxy::Session;
20use std::borrow::Cow;
21use std::sync::Arc;
22use strum::EnumString;
23
24#[derive(
25 PartialEq, Debug, Default, Clone, Copy, EnumString, strum::Display,
26)]
27#[strum(serialize_all = "snake_case")]
28pub enum PluginStep {
29 EarlyRequest,
30 #[default]
31 Request,
32 ProxyUpstream,
33 UpstreamResponse,
34 Response,
35}
36
37/// A more expressive return type for `handle_request`.
38/// It clearly states the plugin's decision.
39#[derive(Debug)]
40pub enum RequestPluginResult {
41 /// The plugin did not run or took no action.
42 Skipped,
43 /// The plugin ran and modified the request; processing should continue.
44 Continue,
45 /// The plugin has decided to terminate the request and send an immediate response.
46 Respond(HttpResponse),
47}
48
49/// Represents the action a plugin takes on a response.
50#[derive(Debug, PartialEq, Eq)]
51pub enum ResponsePluginResult {
52 /// The plugin did not change the response.
53 Unchanged,
54 /// The plugin modified the response (e.g., headers or body).
55 Modified,
56 // TODO
57 // FullyReplaced(HttpResponse),
58}
59
60// Represents the action a plugin task on a response
61#[derive(Debug, PartialEq, Eq)]
62pub enum ResponseBodyPluginResult {
63 /// The plugin did not modify the response body.
64 Unchanged,
65 /// The plugin partially replaced the response body.
66 PartialReplaced,
67 /// The plugin fully replaced the response body.
68 FullyReplaced,
69}
70
71// Manually implement the PartialEq trait for RequestPluginResult
72impl PartialEq for RequestPluginResult {
73 fn eq(&self, other: &Self) -> bool {
74 match (self, other) {
75 // Two Skipped variants are always equal.
76 (RequestPluginResult::Skipped, RequestPluginResult::Skipped) => {
77 true
78 },
79
80 // Two Continue variants are always equal.
81 (RequestPluginResult::Continue, RequestPluginResult::Continue) => {
82 true
83 },
84
85 // Any other combination is not equal.
86 _ => false,
87 }
88 }
89}
90
91/// Core trait that defines the interface all plugins must implement.
92///
93/// Plugins can handle both requests and responses at different processing steps.
94/// The default implementations do nothing and return Ok.
95#[async_trait]
96pub trait Plugin: Sync + Send {
97 /// Returns a unique key that identifies this specific plugin instance.
98 ///
99 /// # Purpose
100 /// - Can be used for caching plugin results
101 /// - Helps differentiate between multiple instances of the same plugin type
102 /// - Useful for tracking and debugging
103 ///
104 /// # Default
105 /// Returns an empty string by default, which means no specific instance identification.
106 fn config_key(&self) -> Cow<'_, str> {
107 Cow::Borrowed("")
108 }
109
110 /// Processes an HTTP request at a specified lifecycle step.
111 ///
112 /// # Parameters
113 /// * `_step` - Current processing step in the request lifecycle (e.g., pre-routing, post-routing)
114 /// * `_session` - Mutable reference to the HTTP session containing request data
115 /// * `_ctx` - Mutable reference to the request context for storing state
116 ///
117 /// # Returns
118 /// * `Ok(result)` where:
119 /// * `result` - The result of the plugin's action on the request
120 /// - `Skipped`: Plugin did not run or took no action
121 /// - `Continue`: Plugin ran and modified the request; processing should continue
122 /// - `Respond(response)`: Plugin has decided to terminate the request and send an immediate response
123 /// * `response` - Optional HTTP response:
124 /// - `Some(response)`: Terminates request processing and returns this response to client
125 /// - `None`: Allows request to continue to next plugin or upstream
126 /// * `Err` - Returns error if plugin processing failed
127 #[inline]
128 async fn handle_request(
129 &self,
130 _step: PluginStep,
131 _session: &mut Session,
132 _ctx: &mut Ctx,
133 ) -> pingora::Result<RequestPluginResult> {
134 Ok(RequestPluginResult::Skipped)
135 }
136
137 /// Processes an HTTP response at a specified lifecycle step.
138 ///
139 /// # Parameters
140 /// * `_session` - Mutable reference to the HTTP session
141 /// * `_ctx` - Mutable reference to the request context
142 /// * `_upstream_response` - Mutable reference to the upstream response header
143 ///
144 /// # Returns
145 /// * `Ok(result)` - The result of the plugin's action on the response
146 /// - `Unchanged`: Plugin did not modify the response
147 /// - `Modified`: Plugin modified the response in some way
148 /// * `Err` - Returns error if plugin processing failed
149 #[inline]
150 async fn handle_response(
151 &self,
152 _session: &mut Session,
153 _ctx: &mut Ctx,
154 _upstream_response: &mut ResponseHeader,
155 ) -> pingora::Result<ResponsePluginResult> {
156 Ok(ResponsePluginResult::Unchanged)
157 }
158
159 /// Whether `handle_response` is also to see the response that another
160 /// plugin answered the request with.
161 ///
162 /// Such a response is sent from the request step and never reaches the
163 /// response step. Only for a plugin whose `handle_response` does
164 /// nothing but set headers: no body handler runs for that response, and
165 /// its length is fixed.
166 #[inline]
167 fn handles_plugin_response(&self) -> bool {
168 false
169 }
170
171 /// The page this plugin has for an error the proxy answers itself -
172 /// no upstream to be had, a request refused by a limit - in place of
173 /// the one of the server: its content type and its body. `None`
174 /// from a plugin that has none, which is every plugin but
175 /// `error_page`, or none for this status.
176 ///
177 /// Asked of the plugins of the location whichever of them have run:
178 /// the error may be one that stopped the request before this plugin
179 /// had its turn.
180 #[inline]
181 fn error_page(
182 &self,
183 _session: &Session,
184 _status: http::StatusCode,
185 _message: &str,
186 ) -> Option<(http::HeaderValue, bytes::Bytes)> {
187 None
188 }
189
190 /// Processes an HTTP response body at a specified lifecycle step.
191 ///
192 /// # Parameters
193 /// * `_session` - Mutable reference to the HTTP session
194 /// * `_ctx` - Mutable reference to the request context
195 /// * `_body` - Mutable reference to the response body
196 /// * `_end_of_stream` - Boolean flag:
197 /// - `true`: The end of the response body has been reached
198 /// - `false`: The response body is still being received
199 ///
200 /// # Returns
201 /// * `Ok(result)` - The result of the plugin's action on the response body
202 /// - `Unchanged`: Plugin did not modify the response body
203 /// - `PartialReplaced(new_body)`: Plugin replaced a part of the response body
204 /// - `FullyReplaced(new_body)`: Plugin replaced the response body with a new one
205 /// * `Err` - Returns error if plugin processing failed
206 #[inline]
207 fn handle_response_body(
208 &self,
209 _session: &mut Session,
210 _ctx: &mut Ctx,
211 _body: &mut Option<bytes::Bytes>,
212 _end_of_stream: bool,
213 ) -> pingora::Result<ResponseBodyPluginResult> {
214 Ok(ResponseBodyPluginResult::Unchanged)
215 }
216
217 /// Processes an upstream response at a specified lifecycle step.
218 ///
219 /// # Parameters
220 /// * `_session` - Mutable reference to the HTTP session
221 /// * `_ctx` - Mutable reference to the request context
222 /// * `_upstream_response` - Mutable reference to the upstream response header
223 ///
224 /// # Returns
225 /// * `Ok(result)` - The result of the plugin's action on the response
226 /// - `Unchanged`: Plugin did not modify the response
227 /// - `Modified`: Plugin modified the response in some way
228 /// * `Err` - Returns error if plugin processing failed
229 #[inline]
230 fn handle_upstream_response(
231 &self,
232 _session: &mut Session,
233 _ctx: &mut Ctx,
234 _upstream_response: &mut ResponseHeader,
235 ) -> pingora::Result<ResponsePluginResult> {
236 Ok(ResponsePluginResult::Unchanged)
237 }
238
239 /// Processes an upstream response body at a specified lifecycle step.
240 ///
241 /// # Parameters
242 /// * `_session` - Mutable reference to the HTTP session
243 /// * `_ctx` - Mutable reference to the request context
244 /// * `_body` - Mutable reference to the upstream response body
245 /// * `_end_of_stream` - Boolean flag:
246 /// - `true`: The end of the upstream response body has been reached
247 /// - `false`: The upstream response body is still being received
248 ///
249 /// # Returns
250 /// * `Ok(result)` - The result of the plugin's action on the response body
251 /// - `Unchanged`: Plugin did not modify the response body
252 /// - `PartialReplaced(new_body)`: Plugin replaced a part of the response body
253 /// - `FullyReplaced(new_body)`: Plugin replaced the response body with a new one
254 /// * `Err` - Returns error if plugin processing failed
255 #[inline]
256 fn handle_upstream_response_body(
257 &self,
258 _session: &mut Session,
259 _ctx: &mut Ctx,
260 _body: &mut Option<bytes::Bytes>,
261 _end_of_stream: bool,
262 ) -> pingora::Result<ResponseBodyPluginResult> {
263 Ok(ResponseBodyPluginResult::Unchanged)
264 }
265}
266
267/// Takes the place of a plugin that a location names but that is not loaded:
268/// the name is misspelled, or the plugin failed to build. It fails every
269/// request of that location.
270///
271/// Leaving the name out instead served the location without the plugin, and
272/// when that plugin is the authentication or the access list, a broken
273/// config turned into an open door.
274pub struct MissingPlugin {
275 name: Arc<str>,
276}
277
278impl MissingPlugin {
279 pub fn new(name: Arc<str>) -> Self {
280 Self { name }
281 }
282}
283
284#[async_trait]
285impl Plugin for MissingPlugin {
286 async fn handle_request(
287 &self,
288 _step: PluginStep,
289 _session: &mut Session,
290 _ctx: &mut Ctx,
291 ) -> pingora::Result<RequestPluginResult> {
292 Err(crate::new_internal_error(
293 500,
294 format!("plugin {} is not available", self.name),
295 ))
296 }
297}
298
299/// Plugin provider trait
300pub trait PluginProvider: Send + Sync {
301 /// Get a plugin by name
302 ///
303 /// # Arguments
304 /// * `name` - The name of the plugin to get
305 ///
306 /// # Returns
307 /// * `Option<Arc<dyn Plugin>>` - The plugin if found, None otherwise
308 fn get(&self, name: &str) -> Option<Arc<dyn Plugin>>;
309
310 /// A number that changes whenever `get` could start returning different
311 /// plugins - after a plugin reload. Locations cache the plugins they
312 /// resolved through `get` and rebuild the list when this moves, so a
313 /// provider whose plugins can change must implement it.
314 fn version(&self) -> u64 {
315 0
316 }
317}
318
319pub type Plugins = AHashMap<String, Arc<dyn Plugin>>;
320
321/// Lets the plugins that ask for it set their headers on a response a
322/// plugin is about to write itself.
323///
324/// A response returned as [`RequestPluginResult::Respond`] gets this from
325/// the proxy. One that a plugin streams to the client on its own - a file
326/// larger than a chunk - never passes there, so it calls this before it
327/// writes the header: without it a small file came with the CORS headers
328/// and a large one did not.
329pub async fn decorate_plugin_response(
330 session: &mut Session,
331 ctx: &mut Ctx,
332 header: &mut ResponseHeader,
333) -> pingora::Result<()> {
334 let Some(plugins) = ctx.response_plugins.take() else {
335 return Ok(());
336 };
337 let mut result = Ok(());
338 for (_, plugin) in plugins.iter() {
339 if !plugin.handles_plugin_response() {
340 continue;
341 }
342 if let Err(e) = plugin.handle_response(session, ctx, header).await {
343 result = Err(e);
344 break;
345 }
346 }
347 ctx.response_plugins = Some(plugins);
348 result
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::NamedPlugin;
355 use pretty_assertions::assert_eq;
356
357 struct Marker {
358 asks: bool,
359 }
360
361 #[async_trait]
362 impl Plugin for Marker {
363 fn handles_plugin_response(&self) -> bool {
364 self.asks
365 }
366 async fn handle_response(
367 &self,
368 _session: &mut Session,
369 _ctx: &mut Ctx,
370 upstream_response: &mut ResponseHeader,
371 ) -> pingora::Result<ResponsePluginResult> {
372 upstream_response.append_header("x-marked", "1")?;
373 Ok(ResponsePluginResult::Modified)
374 }
375 }
376
377 /// Only the plugins that ask for the responses of other plugins see
378 /// one, and the list stays in the context for the next.
379 #[tokio::test]
380 async fn test_decorate_plugin_response() {
381 let mut session = crate::new_test_session(&[], "/").await;
382 let marked = |header: &ResponseHeader| {
383 header.headers.get_all("x-marked").iter().count()
384 };
385
386 // Nothing to ask.
387 let mut ctx = Ctx::default();
388 let mut header = ResponseHeader::build(200, None).unwrap();
389 decorate_plugin_response(&mut session, &mut ctx, &mut header)
390 .await
391 .unwrap();
392 assert_eq!(0, marked(&header));
393
394 let plugins: Vec<NamedPlugin> = vec![
395 (Arc::from("asks"), Arc::new(Marker { asks: true })),
396 (Arc::from("does-not"), Arc::new(Marker { asks: false })),
397 ];
398 ctx.response_plugins = Some(Arc::from(plugins));
399 decorate_plugin_response(&mut session, &mut ctx, &mut header)
400 .await
401 .unwrap();
402 assert_eq!(1, marked(&header));
403 assert_eq!(true, ctx.response_plugins.is_some());
404 }
405
406 #[test]
407 fn test_plugin_step() {
408 let step = "early_request".parse::<PluginStep>().unwrap();
409 assert_eq!(step, PluginStep::EarlyRequest);
410 assert_eq!(step.to_string(), "early_request");
411
412 let step = "request".parse::<PluginStep>().unwrap();
413 assert_eq!(step, PluginStep::Request);
414 assert_eq!(step.to_string(), "request");
415
416 let step = "proxy_upstream".parse::<PluginStep>().unwrap();
417 assert_eq!(step, PluginStep::ProxyUpstream);
418 assert_eq!(step.to_string(), "proxy_upstream");
419
420 let step = "response".parse::<PluginStep>().unwrap();
421 assert_eq!(step, PluginStep::Response);
422 assert_eq!(step.to_string(), "response");
423 }
424
425 #[test]
426 fn test_request_plugin_result() {
427 let skip1 = RequestPluginResult::Skipped;
428 let skip2 = RequestPluginResult::Skipped;
429 assert_eq!(true, skip1 == skip2);
430
431 let continue1 = RequestPluginResult::Continue;
432 let continue2 = RequestPluginResult::Continue;
433 assert_eq!(true, continue1 == continue2);
434
435 let respond1 = RequestPluginResult::Respond(HttpResponse::no_content());
436 let respond2 = RequestPluginResult::Respond(HttpResponse::no_content());
437 assert_eq!(false, respond1 == respond2);
438 }
439}