Skip to main content

systemprompt_models/routing/
mod.rs

1//! Request-routing classification.
2//!
3//! [`RouteClassifier`] maps an incoming request path to a [`RouteType`]
4//! (HTML content, API endpoint, static asset, or not-found), drives
5//! analytics-tracking decisions, and yields the [`EventMetadata`] used
6//! to tag emitted events.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use crate::ContentRouting;
12use crate::modules::ApiPaths;
13use std::path::Path;
14use std::sync::Arc;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct EventMetadata {
18    pub event_type: &'static str,
19    pub event_category: &'static str,
20    pub log_module: &'static str,
21}
22
23impl EventMetadata {
24    pub const HTML_CONTENT: Self = Self {
25        event_type: "page_view",
26        event_category: "content",
27        log_module: "page_view",
28    };
29
30    pub const API_REQUEST: Self = Self {
31        event_type: "http_request",
32        event_category: "api",
33        log_module: "http_request",
34    };
35
36    pub const STATIC_ASSET: Self = Self {
37        event_type: "asset_request",
38        event_category: "static",
39        log_module: "asset_request",
40    };
41
42    pub const NOT_FOUND: Self = Self {
43        event_type: "not_found",
44        event_category: "error",
45        log_module: "not_found",
46    };
47
48    pub const GATEWAY_REQUEST: Self = Self {
49        event_type: "gateway_request",
50        event_category: "gateway",
51        log_module: "gateway_request",
52    };
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum RouteType {
57    HtmlContent { source: String },
58    ApiEndpoint { category: ApiCategory },
59    StaticAsset { asset_type: AssetType },
60    NotFound,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ApiCategory {
65    Content,
66    Core,
67    Agents,
68    OAuth,
69    Gateway,
70    Other,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum AssetType {
75    JavaScript,
76    Stylesheet,
77    Image,
78    Font,
79    SourceMap,
80    Other,
81}
82
83pub struct RouteClassifier {
84    content_routing: Option<Arc<dyn ContentRouting>>,
85}
86
87impl std::fmt::Debug for RouteClassifier {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("RouteClassifier")
90            .field("content_routing", &self.content_routing.is_some())
91            .finish()
92    }
93}
94
95impl RouteClassifier {
96    pub fn new(content_routing: Option<Arc<dyn ContentRouting>>) -> Self {
97        Self { content_routing }
98    }
99
100    pub fn classify(&self, path: &str, _method: &str) -> RouteType {
101        if Self::is_static_asset_path(path) {
102            return RouteType::StaticAsset {
103                asset_type: Self::determine_asset_type(path),
104            };
105        }
106
107        if path.starts_with(ApiPaths::GATEWAY_BASE)
108            || path.starts_with(ApiPaths::GATEWAY_PUBLIC_BASE)
109        {
110            return RouteType::ApiEndpoint {
111                category: ApiCategory::Gateway,
112            };
113        }
114
115        if path.starts_with(ApiPaths::API_BASE) {
116            return RouteType::ApiEndpoint {
117                category: Self::determine_api_category(path),
118            };
119        }
120
121        if path.starts_with(ApiPaths::TRACK_BASE) {
122            return RouteType::ApiEndpoint {
123                category: ApiCategory::Other,
124            };
125        }
126
127        if let Some(routing) = &self.content_routing {
128            if routing.is_html_page(path) {
129                return RouteType::HtmlContent {
130                    source: routing.determine_source(path),
131                };
132            }
133        } else if !Self::is_static_asset_path(path) && !path.starts_with(ApiPaths::API_BASE) {
134            return RouteType::HtmlContent {
135                source: "unknown".to_owned(),
136            };
137        }
138
139        RouteType::NotFound
140    }
141
142    pub fn should_track_analytics(&self, path: &str, method: &str) -> bool {
143        if method == "OPTIONS" {
144            return false;
145        }
146
147        match self.classify(path, method) {
148            RouteType::HtmlContent { .. } => true,
149            RouteType::ApiEndpoint { category } => {
150                matches!(
151                    category,
152                    ApiCategory::Core | ApiCategory::Content | ApiCategory::Other
153                )
154            },
155            RouteType::StaticAsset { .. } | RouteType::NotFound => false,
156        }
157    }
158
159    pub fn is_html(&self, path: &str) -> bool {
160        matches!(self.classify(path, "GET"), RouteType::HtmlContent { .. })
161    }
162
163    pub fn get_event_metadata(&self, path: &str, method: &str) -> EventMetadata {
164        match self.classify(path, method) {
165            RouteType::HtmlContent { .. } => EventMetadata::HTML_CONTENT,
166            RouteType::ApiEndpoint {
167                category: ApiCategory::Gateway,
168            } => EventMetadata::GATEWAY_REQUEST,
169            RouteType::ApiEndpoint { .. } => EventMetadata::API_REQUEST,
170            RouteType::StaticAsset { .. } => EventMetadata::STATIC_ASSET,
171            RouteType::NotFound => EventMetadata::NOT_FOUND,
172        }
173    }
174
175    fn is_static_asset_path(path: &str) -> bool {
176        if path.starts_with(ApiPaths::ASSETS_BASE)
177            || path.starts_with(ApiPaths::WELLKNOWN_BASE)
178            || path.starts_with(ApiPaths::GENERATED_BASE)
179            || path.starts_with(ApiPaths::FILES_BASE)
180        {
181            return true;
182        }
183
184        matches!(
185            Path::new(path).extension().and_then(|e| e.to_str()),
186            Some(
187                "js" | "css"
188                    | "map"
189                    | "ttf"
190                    | "woff"
191                    | "woff2"
192                    | "otf"
193                    | "png"
194                    | "jpg"
195                    | "jpeg"
196                    | "svg"
197                    | "ico"
198                    | "webp"
199                    | "mp4"
200                    | "webm"
201            )
202        ) || path == "/favicon.ico"
203    }
204
205    pub(crate) fn determine_asset_type(path: &str) -> AssetType {
206        match Path::new(path).extension().and_then(|e| e.to_str()) {
207            Some("js") => AssetType::JavaScript,
208            Some("css") => AssetType::Stylesheet,
209            Some("png" | "jpg" | "jpeg" | "svg" | "ico" | "webp") => AssetType::Image,
210            Some("ttf" | "woff" | "woff2" | "otf") => AssetType::Font,
211            Some("map") => AssetType::SourceMap,
212            _ => AssetType::Other,
213        }
214    }
215
216    fn determine_api_category(path: &str) -> ApiCategory {
217        if path.starts_with(ApiPaths::CONTENT_BASE) {
218            ApiCategory::Content
219        } else if path.starts_with(ApiPaths::CORE_BASE) {
220            ApiCategory::Core
221        } else if path.starts_with(ApiPaths::AGENTS_BASE) {
222            ApiCategory::Agents
223        } else if path.starts_with(ApiPaths::OAUTH_BASE) {
224            ApiCategory::OAuth
225        } else {
226            ApiCategory::Other
227        }
228    }
229}